Skip to main content

ff_render/nodes/
transition.rs

1//! Two-clip transition nodes: a directional wipe, a linear fade, a per-pixel dissolve,
2//! and a two-phase dip to a solid colour. Like [`CrossfadeNode`](super::crossfade::CrossfadeNode), the second clip
3//! (B) is carried as RGBA bytes in the node and uploaded to a GPU texture at render
4//! time: a single render graph has one source, so B cannot arrive as a second GPU
5//! input. Clip A is the node's input (`inputs[0]` / the `process_cpu` argument).
6
7use super::RenderNodeCpu;
8
9/// Linear interpolation `a + (b - a) * t`, rounded to the nearest byte.
10#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
11fn lerp_u8(a: f32, b: f32, t: f32) -> u8 {
12    (a + (b - a) * t + 0.5).clamp(0.0, 255.0) as u8
13}
14
15/// `FFmpeg`'s fixed dip phase (`vf_xfade.c`, `FADEBLACK_TRANSITION`): the fraction of
16/// the transition spent reaching the solid colour at each end.
17///
18/// It is what makes the dip *not* a linear ramp -- the solid colour is reached about a
19/// fifth of the way in and held through the middle, where a linear dip would only touch
20/// it at the midpoint. The linear version this replaced diverged from a real export by a
21/// mean of 78 (#1732).
22const DIP_PHASE: f32 = 0.2;
23
24/// `smoothstep(e0, e1, x)`; callers guarantee `e1 > e0`.
25fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
26    let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
27    t * t * (3.0 - 2.0 * t)
28}
29
30// WipeTransitionNode
31
32/// Directional wipe that reveals clip B behind a moving edge.
33///
34/// `progress = 0` outputs clip A, `progress = 1` outputs clip B, and an edge sweeps
35/// across the frame between them. `angle` (radians) points along the axis clip B
36/// **grows from**, because the mask fills where the projection
37/// exceeds the sweeping threshold: at `angle = 0` clip B enters from the **right** and
38/// the edge travels right-to-left; at `angle = π/2` it enters from the **bottom**.
39///
40/// That is the opposite of how `FFmpeg` names its `xfade` wipes — `wiperight` sweeps its
41/// edge rightward, so clip B enters from the left and maps to `angle = π` (RK-020).
42///
43/// `softness` feathers the edge (normalised units).
44pub struct WipeTransitionNode {
45    /// Transition progress `[0, 1]`: 0 = clip A, 1 = clip B.
46    pub progress: f32,
47    /// Edge feather half-width in normalised units (0 = hard edge).
48    pub softness: f32,
49    /// Axis clip B grows from, in radians: `0` = from the right, `π/2` = from the
50    /// bottom. See the type docs — this is the opposite of the `FFmpeg` wipe names.
51    pub angle: f32,
52    /// Clip B as RGBA bytes (`to_width × to_height × 4`).
53    pub to_rgba: Vec<u8>,
54    /// Width of `to_rgba`.
55    pub to_width: u32,
56    /// Height of `to_rgba`.
57    pub to_height: u32,
58}
59
60impl WipeTransitionNode {
61    /// Creates a wipe from clip A (the node input) to clip B (`to_rgba`).
62    #[must_use]
63    pub fn new(
64        progress: f32,
65        softness: f32,
66        angle: f32,
67        to_rgba: Vec<u8>,
68        to_width: u32,
69        to_height: u32,
70    ) -> Self {
71        Self {
72            progress,
73            softness,
74            angle,
75            to_rgba,
76            to_width,
77            to_height,
78        }
79    }
80
81    /// The B-weight (`mask`) for the pixel at `(x, y)` on a `w` x `h` grid. Shared by
82    /// the CPU and GPU paths (`wipe.wgsl` uses the identical formula) so they agree.
83    ///
84    /// Clip B occupies the side where `proj` exceeds `center`, and `center` sweeps down
85    /// as `progress` rises -- so B fills in from the **high** end of the projected axis.
86    ///
87    /// A hard edge (`softness == 0`) along one of the four axes takes an exact integer
88    /// rule instead, because those are the four `FFmpeg` wipes and the export has to be
89    /// able to reproduce them. `FFmpeg` compares the pixel index against an integer edge
90    /// `z` (`vf_xfade.c`, `WIPE*_TRANSITION`), which puts the seam half a pixel away from
91    /// where a normalised threshold puts it -- one column, but a column that a per-pixel
92    /// comparison sees (#1732). The rule is deliberately asymmetric at the endpoints,
93    /// matching `FFmpeg`: the `-x` axis already shows one column of B at progress 0.
94    ///
95    /// A feathered or off-axis wipe has no `FFmpeg` counterpart to match and keeps the
96    /// smoothstep.
97    fn mask_at(&self, x: u32, y: u32, w: u32, h: u32) -> f32 {
98        let (ax, ay) = (self.angle.cos(), self.angle.sin());
99        if self.softness <= 0.0 {
100            const AXIS: f32 = 0.999;
101            #[allow(clippy::cast_precision_loss)]
102            let (wf, hf) = (w as f32, h as f32);
103            // `z` truncates exactly as C's `const int z = width * progress` does.
104            #[allow(clippy::cast_possible_truncation)]
105            let edge = |extent: f32, at: f32| (extent * at) as i64;
106            if ax > AXIS {
107                return f32::from(i64::from(x) > edge(wf, 1.0 - self.progress));
108            }
109            if ax < -AXIS {
110                return f32::from(i64::from(x) <= edge(wf, self.progress));
111            }
112            if ay > AXIS {
113                return f32::from(i64::from(y) > edge(hf, 1.0 - self.progress));
114            }
115            if ay < -AXIS {
116                return f32::from(i64::from(y) <= edge(hf, self.progress));
117            }
118        }
119        #[allow(clippy::cast_precision_loss)]
120        let (uv_x, uv_y) = ((x as f32 + 0.5) / w as f32, (y as f32 + 0.5) / h as f32);
121        let reach = f32::midpoint(ax.abs(), ay.abs());
122        // Floor the half-width so a zero softness is a near-hard, division-safe edge.
123        let hw = self.softness.max(1e-3);
124        // Sweep the threshold from beyond the far corner (all A at progress 0) to
125        // beyond the near corner (all B at progress 1), so the endpoints are exact.
126        let center = (0.5 + reach + hw) + ((0.5 - reach - hw) - (0.5 + reach + hw)) * self.progress;
127        let proj = (uv_x - 0.5) * ax + (uv_y - 0.5) * ay + 0.5;
128        smoothstep(center - hw, center + hw, proj)
129    }
130}
131
132impl RenderNodeCpu for WipeTransitionNode {
133    #[allow(clippy::cast_precision_loss)]
134    fn process_cpu(&self, rgba: &mut [u8], w: u32, h: u32) {
135        if self.to_rgba.len() != rgba.len() {
136            log::warn!(
137                "WipeTransitionNode::process_cpu skipped: size mismatch a={} b={}",
138                rgba.len(),
139                self.to_rgba.len()
140            );
141            return;
142        }
143        for y in 0..h {
144            for x in 0..w {
145                let idx = ((y * w + x) * 4) as usize;
146                let mask = self.mask_at(x, y, w, h);
147                for c in 0..4 {
148                    let a = f32::from(rgba[idx + c]);
149                    let b = f32::from(self.to_rgba[idx + c]);
150                    rgba[idx + c] = lerp_u8(a, b, mask);
151                }
152            }
153        }
154    }
155}
156
157// FadeTransitionNode
158
159/// Linear cross-blend: clip A mixed into clip B by `progress`.
160///
161/// This is `FFmpeg`'s `xfade=transition=fade`, not its `dissolve` — `dissolve` reveals
162/// clip B one pixel at a time and never produces a mixed value (see
163/// [`DissolveTransitionNode`]).
164///
165/// `progress = 0` outputs clip A, `progress = 1` outputs clip B, and every value
166/// between is the per-channel mix of the two (alpha included, as the sibling
167/// transitions do).
168///
169/// The GPU path reuses `crossfade.wgsl` rather than carrying a second copy of the same
170/// `mix`: that shader's bindings already match the layout this module's shared
171/// `build_pipeline` sets up, and its single-`f32` uniform is this node's `progress`. It
172/// is therefore the same operation as [`CrossfadeNode`](super::crossfade::CrossfadeNode),
173/// exposed with the `progress` / `to_rgba` shape the rest of this module uses so a
174/// transition set can be mapped uniformly.
175///
176/// Like its siblings this node renders to an `Rgba8Unorm` target (the shared
177/// `build_pipeline` hard-codes that format), so it does not run in an `Rgba16Float`
178/// graph.
179pub struct FadeTransitionNode {
180    /// Transition progress `[0, 1]`: 0 = clip A, 1 = clip B.
181    pub progress: f32,
182    /// Clip B as RGBA bytes (`to_width × to_height × 4`).
183    pub to_rgba: Vec<u8>,
184    /// Width of `to_rgba`.
185    pub to_width: u32,
186    /// Height of `to_rgba`.
187    pub to_height: u32,
188}
189
190impl FadeTransitionNode {
191    /// Creates a fade from clip A (the node input) to clip B (`to_rgba`).
192    #[must_use]
193    pub fn new(progress: f32, to_rgba: Vec<u8>, to_width: u32, to_height: u32) -> Self {
194        Self {
195            progress,
196            to_rgba,
197            to_width,
198            to_height,
199        }
200    }
201}
202
203impl RenderNodeCpu for FadeTransitionNode {
204    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
205        if self.to_rgba.len() != rgba.len() {
206            log::warn!(
207                "FadeTransitionNode::process_cpu skipped: size mismatch a={} b={}",
208                rgba.len(),
209                self.to_rgba.len()
210            );
211            return;
212        }
213        for (a, b) in rgba.iter_mut().zip(self.to_rgba.iter()) {
214            *a = lerp_u8(f32::from(*a), f32::from(*b), self.progress);
215        }
216    }
217}
218
219// DissolveTransitionNode
220
221/// Per-pixel dissolve: clip B shows through wherever the supplied `mask` is set.
222///
223/// This is `FFmpeg`'s `xfade=transition=dissolve`. Unlike [`FadeTransitionNode`] every
224/// output pixel is *fully* clip A or *fully* clip B, never a mixture of them — the node
225/// selects, it does not blend.
226///
227/// It takes no progress of its own: which pixels have turned over is entirely the mask's
228/// decision, and `ff_filter::dissolve_mask` is what turns a progress into one.
229///
230/// Like its siblings this node renders to an `Rgba8Unorm` target (the shared
231/// `build_pipeline` hard-codes that format), so it does not run in an `Rgba16Float`
232/// graph.
233pub struct DissolveTransitionNode {
234    /// Per-pixel selection as an RGBA mask: `255` shows clip B, `0` shows clip A.
235    ///
236    /// Supplied rather than computed. `FFmpeg`'s dissolve keys off
237    /// `fract(sinf(x*12.9898 + y*78.233) * 43758.545)`, whose argument reaches ~110 000
238    /// at 1080p -- past where `f32` holds it steadily, so the value is not reproducible
239    /// across implementations and a `WGSL` copy would reveal a different set of pixels
240    /// than the CPU reference. `ff_filter::dissolve_mask` builds this once and both
241    /// paths read it (#1732).
242    pub mask: Vec<u8>,
243    /// Clip B as RGBA bytes (`to_width × to_height × 4`).
244    pub to_rgba: Vec<u8>,
245    /// Width of `to_rgba`.
246    pub to_width: u32,
247    /// Height of `to_rgba`.
248    pub to_height: u32,
249}
250
251impl DissolveTransitionNode {
252    /// Creates a dissolve from clip A (the node input) to clip B (`to_rgba`), revealing B
253    /// wherever `mask` is set. Build `mask` with `ff_filter::dissolve_mask`.
254    #[must_use]
255    pub fn new(mask: Vec<u8>, to_rgba: Vec<u8>, to_width: u32, to_height: u32) -> Self {
256        Self {
257            mask,
258            to_rgba,
259            to_width,
260            to_height,
261        }
262    }
263}
264
265impl RenderNodeCpu for DissolveTransitionNode {
266    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
267        if self.to_rgba.len() != rgba.len() || self.mask.len() != rgba.len() {
268            log::warn!(
269                "DissolveTransitionNode::process_cpu skipped: size mismatch a={} b={} mask={}",
270                rgba.len(),
271                self.to_rgba.len(),
272                self.mask.len()
273            );
274            return;
275        }
276        for ((px, b), m) in rgba
277            .as_chunks_mut::<4>()
278            .0
279            .iter_mut()
280            .zip(self.to_rgba.as_chunks::<4>().0)
281            .zip(self.mask.as_chunks::<4>().0)
282        {
283            if m[0] >= 128 {
284                *px = *b;
285            }
286        }
287    }
288}
289
290// DipToColorNode
291
292/// Two-phase transition: clip A fades to a solid `color`, then the colour fades to
293/// clip B. `progress = 0.5` is the fully solid dip (a fade-to-black/white/brand dip).
294pub struct DipToColorNode {
295    /// Transition progress `[0, 1]`: 0 = clip A, 1 = clip B, with `color` solid across
296    /// the middle (see this module's `DIP_PHASE`).
297    pub progress: f32,
298    /// Dip colour in RGB, normally `[0, 1]`.
299    ///
300    /// Values **outside** that range are meaningful and are not clamped until the final
301    /// write: reproducing `FFmpeg`'s `fadeblack` / `fadewhite` needs the dip endpoint to
302    /// be the luma level 0 / 255 expanded out of limited range, which lands just outside
303    /// `[0, 1]`. `avio`'s `map_transition` is what supplies those values.
304    pub color: [f32; 3],
305    /// Clip B as RGBA bytes (`to_width × to_height × 4`).
306    pub to_rgba: Vec<u8>,
307    /// Width of `to_rgba`.
308    pub to_width: u32,
309    /// Height of `to_rgba`.
310    pub to_height: u32,
311}
312
313impl DipToColorNode {
314    /// Creates a dip-to-colour transition from clip A to clip B (`to_rgba`).
315    #[must_use]
316    pub fn new(
317        progress: f32,
318        color: [f32; 3],
319        to_rgba: Vec<u8>,
320        to_width: u32,
321        to_height: u32,
322    ) -> Self {
323        Self {
324            progress,
325            color,
326            to_rgba,
327            to_width,
328            to_height,
329        }
330    }
331}
332
333impl RenderNodeCpu for DipToColorNode {
334    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
335        if self.to_rgba.len() != rgba.len() {
336            log::warn!(
337                "DipToColorNode::process_cpu skipped: size mismatch a={} b={}",
338                rgba.len(),
339                self.to_rgba.len()
340            );
341            return;
342        }
343        let bg = [
344            self.color[0] * 255.0,
345            self.color[1] * 255.0,
346            self.color[2] * 255.0,
347            255.0,
348        ];
349        // `FFmpeg`'s progress is the complement of ours, and both curves are constant
350        // over the frame, so they are evaluated once rather than per pixel.
351        let g = 1.0 - self.progress;
352        let s1 = smoothstep(1.0 - DIP_PHASE, 1.0, g);
353        let s2 = smoothstep(DIP_PHASE, 1.0, g);
354        for (px, b) in rgba
355            .as_chunks_mut::<4>()
356            .0
357            .iter_mut()
358            .zip(self.to_rgba.as_chunks::<4>().0)
359        {
360            for c in 0..4 {
361                let leaving = f32::from(px[c]) * s1 + bg[c] * (1.0 - s1);
362                let arriving = bg[c] * s2 + f32::from(b[c]) * (1.0 - s2);
363                // `lerp_u8(a, b, t)` is `a + (b - a) * t`, so this is
364                // `leaving * g + arriving * (1 - g)` -- FFmpeg's outer `mix`.
365                px[c] = lerp_u8(arriving, leaving, g);
366            }
367        }
368    }
369}
370
371// GPU path (shared 2-input plumbing)
372
373/// Identifies a compiled transition pipeline: every input `build_pipeline` bakes in
374/// except the shader source, which the label stands for.
375#[cfg(feature = "wgpu")]
376#[derive(PartialEq, Eq, Hash, Clone, Copy)]
377pub(crate) struct TransitionPipelineKey {
378    pub(crate) label: &'static str,
379    pub(crate) uniform_size: u64,
380    pub(crate) mask: bool,
381}
382
383#[cfg(feature = "wgpu")]
384pub(crate) struct TransitionPipeline {
385    render_pipeline: wgpu::RenderPipeline,
386    bind_group_layout: wgpu::BindGroupLayout,
387    sampler: wgpu::Sampler,
388    uniform_buf: wgpu::Buffer,
389}
390
391#[cfg(feature = "wgpu")]
392fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
393    wgpu::BindGroupLayoutEntry {
394        binding,
395        visibility: wgpu::ShaderStages::FRAGMENT,
396        ty: wgpu::BindingType::Texture {
397            sample_type: wgpu::TextureSampleType::Float { filterable: true },
398            view_dimension: wgpu::TextureViewDimension::D2,
399            multisampled: false,
400        },
401        count: None,
402    }
403}
404
405/// The compiled pipeline for `label`, built once per device and reused afterwards.
406///
407/// Transition nodes are constructed per frame (they own the incoming clip's pixels), so
408/// without this the shader would be compiled per frame: measured at roughly 5 ms a time,
409/// which a 30 fps preview cannot spend. The pipeline is a pure function of the shader and
410/// the layout, so one entry serves every node of that kind on this device (#1726).
411#[cfg(feature = "wgpu")]
412fn cached_pipeline(
413    ctx: &crate::context::RenderContext,
414    label: &'static str,
415    shader_src: &str,
416    uniform_size: u64,
417    mask: bool,
418) -> std::sync::Arc<TransitionPipeline> {
419    let mut cache = match ctx.transition_pipelines.lock() {
420        Ok(guard) => guard,
421        // A poisoned lock means another thread panicked mid-build. The cache holds only
422        // derived data, so recovering it is safe and better than propagating the panic
423        // into a render pass.
424        Err(poisoned) => poisoned.into_inner(),
425    };
426    // Keyed by everything `build_pipeline` bakes in, not by the label alone. The four
427    // labels happen to determine the rest today, but nothing enforces that, and a fifth
428    // node reusing a label would otherwise be handed another shader's pipeline (RK-025:
429    // a cache key has to reflect every input the cached thing was built from). The
430    // colour target is absent because `build_pipeline` hard-codes `Rgba8Unorm` for all
431    // of them; if that ever varies it belongs in this key too.
432    let key = TransitionPipelineKey {
433        label,
434        uniform_size,
435        mask,
436    };
437    std::sync::Arc::clone(cache.entry(key).or_insert_with(|| {
438        std::sync::Arc::new(build_pipeline(
439            &ctx.device,
440            shader_src,
441            label,
442            uniform_size,
443            mask,
444        ))
445    }))
446}
447
448/// Builds the shared transition pipeline: bind group `tex_a` / `tex_b` / sampler /
449/// uniform, a uniform buffer of `uniform_size` bytes, and -- when `mask` is set -- a
450/// third texture at binding 4 carrying a per-pixel selection mask.
451///
452/// Only `DissolveTransitionNode` asks for the mask. The other three shaders declare
453/// exactly the four bindings above, so handing them a layout entry they never read would
454/// be dead surface.
455#[cfg(feature = "wgpu")]
456fn build_pipeline(
457    device: &wgpu::Device,
458    shader_src: &str,
459    label: &str,
460    uniform_size: u64,
461    mask: bool,
462) -> TransitionPipeline {
463    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
464        label: Some(label),
465        source: wgpu::ShaderSource::Wgsl(shader_src.into()),
466    });
467    let mut entries = vec![
468        tex_entry(0),
469        tex_entry(1),
470        wgpu::BindGroupLayoutEntry {
471            binding: 2,
472            visibility: wgpu::ShaderStages::FRAGMENT,
473            ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
474            count: None,
475        },
476        wgpu::BindGroupLayoutEntry {
477            binding: 3,
478            visibility: wgpu::ShaderStages::FRAGMENT,
479            ty: wgpu::BindingType::Buffer {
480                ty: wgpu::BufferBindingType::Uniform,
481                has_dynamic_offset: false,
482                min_binding_size: None,
483            },
484            count: None,
485        },
486    ];
487    if mask {
488        entries.push(tex_entry(4));
489    }
490    let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
491        label: Some(label),
492        entries: &entries,
493    });
494    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
495        label: Some(label),
496        bind_group_layouts: &[Some(&bgl)],
497        immediate_size: 0,
498    });
499    let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
500        label: Some(label),
501        layout: Some(&pipeline_layout),
502        vertex: wgpu::VertexState {
503            module: &shader,
504            entry_point: Some("vs_main"),
505            buffers: &[],
506            compilation_options: wgpu::PipelineCompilationOptions::default(),
507        },
508        fragment: Some(wgpu::FragmentState {
509            module: &shader,
510            entry_point: Some("fs_main"),
511            targets: &[Some(wgpu::ColorTargetState {
512                format: wgpu::TextureFormat::Rgba8Unorm,
513                blend: None,
514                write_mask: wgpu::ColorWrites::ALL,
515            })],
516            compilation_options: wgpu::PipelineCompilationOptions::default(),
517        }),
518        primitive: wgpu::PrimitiveState::default(),
519        depth_stencil: None,
520        multisample: wgpu::MultisampleState::default(),
521        multiview_mask: None,
522        cache: None,
523    });
524    let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
525        label: Some(label),
526        address_mode_u: wgpu::AddressMode::ClampToEdge,
527        address_mode_v: wgpu::AddressMode::ClampToEdge,
528        mag_filter: wgpu::FilterMode::Linear,
529        min_filter: wgpu::FilterMode::Linear,
530        ..Default::default()
531    });
532    let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
533        label: Some(label),
534        size: uniform_size,
535        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
536        mapped_at_creation: false,
537    });
538    TransitionPipeline {
539        render_pipeline,
540        bind_group_layout: bgl,
541        sampler,
542        uniform_buf,
543    }
544}
545
546/// Uploads clip B to a temporary `Rgba8Unorm` texture.
547#[cfg(feature = "wgpu")]
548fn upload_frame(
549    ctx: &crate::context::RenderContext,
550    rgba: &[u8],
551    width: u32,
552    height: u32,
553) -> wgpu::Texture {
554    let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
555        label: Some("Transition to_tex"),
556        size: wgpu::Extent3d {
557            width,
558            height,
559            depth_or_array_layers: 1,
560        },
561        mip_level_count: 1,
562        sample_count: 1,
563        dimension: wgpu::TextureDimension::D2,
564        format: wgpu::TextureFormat::Rgba8Unorm,
565        usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
566        view_formats: &[],
567    });
568    ctx.queue.write_texture(
569        wgpu::TexelCopyTextureInfo {
570            texture: &tex,
571            mip_level: 0,
572            origin: wgpu::Origin3d::ZERO,
573            aspect: wgpu::TextureAspect::All,
574        },
575        rgba,
576        wgpu::TexelCopyBufferLayout {
577            offset: 0,
578            bytes_per_row: Some(width * 4),
579            rows_per_image: None,
580        },
581        wgpu::Extent3d {
582            width,
583            height,
584            depth_or_array_layers: 1,
585        },
586    );
587    tex
588}
589
590/// Binds `tex_a` / `tex_b` / sampler / uniform and runs the full-screen pass.
591#[cfg(feature = "wgpu")]
592fn run_pass(
593    ctx: &crate::context::RenderContext,
594    pd: &TransitionPipeline,
595    tex_a: &wgpu::Texture,
596    tex_b: &wgpu::Texture,
597    mask: Option<&wgpu::Texture>,
598    output: &wgpu::Texture,
599    label: &str,
600) {
601    let a_view = tex_a.create_view(&wgpu::TextureViewDescriptor::default());
602    let b_view = tex_b.create_view(&wgpu::TextureViewDescriptor::default());
603    let mask_view = mask.map(|m| m.create_view(&wgpu::TextureViewDescriptor::default()));
604    let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
605    let mut bind_entries = vec![
606        wgpu::BindGroupEntry {
607            binding: 0,
608            resource: wgpu::BindingResource::TextureView(&a_view),
609        },
610        wgpu::BindGroupEntry {
611            binding: 1,
612            resource: wgpu::BindingResource::TextureView(&b_view),
613        },
614        wgpu::BindGroupEntry {
615            binding: 2,
616            resource: wgpu::BindingResource::Sampler(&pd.sampler),
617        },
618        wgpu::BindGroupEntry {
619            binding: 3,
620            resource: pd.uniform_buf.as_entire_binding(),
621        },
622    ];
623    if let Some(view) = mask_view.as_ref() {
624        bind_entries.push(wgpu::BindGroupEntry {
625            binding: 4,
626            resource: wgpu::BindingResource::TextureView(view),
627        });
628    }
629    let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
630        label: Some(label),
631        layout: &pd.bind_group_layout,
632        entries: &bind_entries,
633    });
634    let mut encoder = ctx
635        .device
636        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
637    {
638        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
639            label: Some(label),
640            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
641                view: &out_view,
642                resolve_target: None,
643                depth_slice: None,
644                ops: wgpu::Operations {
645                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
646                    store: wgpu::StoreOp::Store,
647                },
648            })],
649            depth_stencil_attachment: None,
650            timestamp_writes: None,
651            occlusion_query_set: None,
652            multiview_mask: None,
653        });
654        pass.set_pipeline(&pd.render_pipeline);
655        pass.set_bind_group(0, &bind_group, &[]);
656        pass.draw(0..6, 0..1);
657    }
658    ctx.queue.submit(std::iter::once(encoder.finish()));
659}
660
661#[cfg(feature = "wgpu")]
662fn pack_f32(values: &[f32]) -> Vec<u8> {
663    values.iter().flat_map(|f| f.to_le_bytes()).collect()
664}
665
666#[cfg(feature = "wgpu")]
667impl super::RenderNode for WipeTransitionNode {
668    fn input_count(&self) -> usize {
669        2
670    }
671
672    fn process(
673        &self,
674        inputs: &[&wgpu::Texture],
675        outputs: &[&wgpu::Texture],
676        ctx: &crate::context::RenderContext,
677    ) {
678        let Some(tex_a) = inputs.first() else {
679            log::warn!("WipeTransitionNode::process called with no inputs");
680            return;
681        };
682        let Some(output) = outputs.first() else {
683            log::warn!("WipeTransitionNode::process called with no outputs");
684            return;
685        };
686        let pd = cached_pipeline(ctx, "Wipe", include_str!("../shaders/wipe.wgsl"), 16, false);
687        ctx.queue.write_buffer(
688            &pd.uniform_buf,
689            0,
690            &pack_f32(&[self.progress, self.softness, self.angle, 0.0]),
691        );
692        let to_tex = upload_frame(ctx, &self.to_rgba, self.to_width, self.to_height);
693        run_pass(ctx, &pd, tex_a, &to_tex, None, output, "Wipe pass");
694    }
695}
696
697#[cfg(feature = "wgpu")]
698impl super::RenderNode for FadeTransitionNode {
699    fn input_count(&self) -> usize {
700        2
701    }
702
703    fn process(
704        &self,
705        inputs: &[&wgpu::Texture],
706        outputs: &[&wgpu::Texture],
707        ctx: &crate::context::RenderContext,
708    ) {
709        let Some(tex_a) = inputs.first() else {
710            log::warn!("FadeTransitionNode::process called with no inputs");
711            return;
712        };
713        let Some(output) = outputs.first() else {
714            log::warn!("FadeTransitionNode::process called with no outputs");
715            return;
716        };
717        // `crossfade.wgsl` is this node's shader: same binding layout as the other
718        // transitions, and its one `f32` uniform is `progress` (see the type docs).
719        let pd = cached_pipeline(
720            ctx,
721            "Fade",
722            include_str!("../shaders/crossfade.wgsl"),
723            16,
724            false,
725        );
726        ctx.queue.write_buffer(
727            &pd.uniform_buf,
728            0,
729            &pack_f32(&[self.progress, 0.0, 0.0, 0.0]),
730        );
731        let to_tex = upload_frame(ctx, &self.to_rgba, self.to_width, self.to_height);
732        run_pass(ctx, &pd, tex_a, &to_tex, None, output, "Fade pass");
733    }
734}
735
736#[cfg(feature = "wgpu")]
737impl super::RenderNode for DissolveTransitionNode {
738    fn input_count(&self) -> usize {
739        2
740    }
741
742    fn process(
743        &self,
744        inputs: &[&wgpu::Texture],
745        outputs: &[&wgpu::Texture],
746        ctx: &crate::context::RenderContext,
747    ) {
748        let Some(tex_a) = inputs.first() else {
749            log::warn!("DissolveTransitionNode::process called with no inputs");
750            return;
751        };
752        let Some(output) = outputs.first() else {
753            log::warn!("DissolveTransitionNode::process called with no outputs");
754            return;
755        };
756        // The only transition that binds a third texture, so the only one that asks
757        // `build_pipeline` for the mask entry.
758        let pd = cached_pipeline(
759            ctx,
760            "Dissolve",
761            include_str!("../shaders/dissolve.wgsl"),
762            16,
763            true,
764        );
765        let to_tex = upload_frame(ctx, &self.to_rgba, self.to_width, self.to_height);
766        let mask_tex = upload_frame(ctx, &self.mask, self.to_width, self.to_height);
767        run_pass(
768            ctx,
769            &pd,
770            tex_a,
771            &to_tex,
772            Some(&mask_tex),
773            output,
774            "Dissolve pass",
775        );
776    }
777}
778
779#[cfg(feature = "wgpu")]
780impl super::RenderNode for DipToColorNode {
781    fn input_count(&self) -> usize {
782        2
783    }
784
785    fn process(
786        &self,
787        inputs: &[&wgpu::Texture],
788        outputs: &[&wgpu::Texture],
789        ctx: &crate::context::RenderContext,
790    ) {
791        let Some(tex_a) = inputs.first() else {
792            log::warn!("DipToColorNode::process called with no inputs");
793            return;
794        };
795        let Some(output) = outputs.first() else {
796            log::warn!("DipToColorNode::process called with no outputs");
797            return;
798        };
799        let pd = cached_pipeline(ctx, "Dip", include_str!("../shaders/dip.wgsl"), 32, false);
800        ctx.queue.write_buffer(
801            &pd.uniform_buf,
802            0,
803            &pack_f32(&[
804                self.progress,
805                0.0,
806                0.0,
807                0.0,
808                self.color[0],
809                self.color[1],
810                self.color[2],
811                1.0,
812            ]),
813        );
814        let to_tex = upload_frame(ctx, &self.to_rgba, self.to_width, self.to_height);
815        run_pass(ctx, &pd, tex_a, &to_tex, None, output, "Dip pass");
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    #[test]
824    fn wipe_progress_zero_should_be_clip_a() {
825        let b = vec![200u8, 200, 200, 255];
826        let node = WipeTransitionNode::new(0.0, 0.0, 0.0, b, 1, 1);
827        let a = vec![10u8, 20, 30, 255];
828        let mut rgba = a.clone();
829        node.process_cpu(&mut rgba, 1, 1);
830        assert_eq!(rgba, a, "progress=0 must output clip A");
831    }
832
833    #[test]
834    fn wipe_at_progress_one_should_keep_ffmpegs_final_column() {
835        // `FFmpeg`'s edge is an integer and its comparison is strict, so the last column
836        // never flips: at progress 1 the axis-`+x` rule is `x > floor(w * 0)` = `x > 0`,
837        // leaving column 0 on clip A. Reproducing that asymmetry is the point -- the
838        // export has to land on FFmpeg's pixels, not on a tidier convention (#1732).
839        let a = vec![
840            10u8, 20, 30, 255, 10, 20, 30, 255, 10, 20, 30, 255, 10, 20, 30, 255,
841        ];
842        let b = vec![
843            200u8, 210, 220, 255, 200, 210, 220, 255, 200, 210, 220, 255, 200, 210, 220, 255,
844        ];
845        let node = WipeTransitionNode::new(1.0, 0.0, 0.0, b, 4, 1);
846        let mut rgba = a.clone();
847        node.process_cpu(&mut rgba, 4, 1);
848        assert_eq!(
849            &rgba[0..4],
850            &a[0..4],
851            "column 0 stays on clip A at progress 1"
852        );
853        for x in 1..4 {
854            assert_eq!(
855                &rgba[x * 4..x * 4 + 3],
856                &[200, 210, 220],
857                "column {x} must be clip B at progress 1"
858            );
859        }
860    }
861
862    #[test]
863    fn wipe_hard_edge_should_land_on_ffmpegs_integer_column() {
864        // 8x1, angle 0 (axis +x), softness 0, progress 0.5. `FFmpeg`'s WIPELEFT computes
865        // `z = width * (1 - progress) = 4` and takes clip B where `x > z`, so columns
866        // 0..=4 are A and 5..=7 are B -- an asymmetric split, not four and four. A
867        // normalised threshold puts the seam a column earlier, which is the entire
868        // divergence this rule fixes (#1732).
869        let a: Vec<u8> = (0..8).flat_map(|_| [10u8, 20, 30, 255]).collect();
870        let b: Vec<u8> = (0..8).flat_map(|_| [200u8, 210, 220, 255]).collect();
871        let node = WipeTransitionNode::new(0.5, 0.0, 0.0, b, 8, 1);
872        let mut rgba = a.clone();
873        node.process_cpu(&mut rgba, 8, 1);
874        for x in 0..8 {
875            let want: [u8; 3] = if x > 4 { [200, 210, 220] } else { [10, 20, 30] };
876            assert_eq!(
877                &rgba[x * 4..x * 4 + 3],
878                &want,
879                "column {x} at progress 0.5 (FFmpeg edge z=4)"
880            );
881        }
882    }
883
884    #[test]
885    fn wipe_size_mismatch_should_leave_rgba_unchanged() {
886        let b = vec![200u8; 8]; // 2 px
887        let node = WipeTransitionNode::new(0.5, 0.0, 0.0, b, 2, 1);
888        let original = vec![10u8, 20, 30, 255]; // 1 px
889        let mut rgba = original.clone();
890        node.process_cpu(&mut rgba, 1, 1);
891        assert_eq!(rgba, original, "size mismatch must be a no-op");
892    }
893
894    /// Clip A and clip B of the dissolve tests: every channel differs between the two
895    /// and no channel repeats within a frame, so a swapped pair, a dropped channel or a
896    /// transposed one all show up in the assertions below.
897    const FADE_A: [u8; 4] = [10, 200, 30, 255];
898    const FADE_B: [u8; 4] = [210, 40, 130, 55];
899
900    #[test]
901    fn fade_transition_progress_zero_should_be_clip_a() {
902        let node = FadeTransitionNode::new(0.0, FADE_B.to_vec(), 1, 1);
903        let mut rgba = FADE_A.to_vec();
904        node.process_cpu(&mut rgba, 1, 1);
905        assert_eq!(rgba, FADE_A, "progress=0 must output clip A");
906    }
907
908    #[test]
909    fn fade_transition_progress_one_should_be_clip_b() {
910        let node = FadeTransitionNode::new(1.0, FADE_B.to_vec(), 1, 1);
911        let mut rgba = FADE_A.to_vec();
912        node.process_cpu(&mut rgba, 1, 1);
913        assert_eq!(rgba, FADE_B, "progress=1 must output clip B");
914    }
915
916    #[test]
917    fn fade_transition_half_should_average_the_pair() {
918        // The acceptance criterion. On its own it cannot tell A from B (the mix is
919        // symmetric at 0.5); the endpoint tests above are what pin the direction.
920        let node = FadeTransitionNode::new(0.5, FADE_B.to_vec(), 1, 1);
921        let mut rgba = FADE_A.to_vec();
922        node.process_cpu(&mut rgba, 1, 1);
923        for (c, got) in rgba.iter().enumerate() {
924            let want = f32::midpoint(f32::from(FADE_A[c]), f32::from(FADE_B[c]));
925            assert!(
926                (f32::from(*got) - want).abs() <= 1.0,
927                "channel {c}: got {got} want {want}"
928            );
929        }
930    }
931
932    #[test]
933    fn fade_transition_size_mismatch_should_leave_rgba_unchanged() {
934        let node = FadeTransitionNode::new(0.5, vec![200u8; 8], 2, 1); // 2 px of B
935        let original = FADE_A.to_vec(); // 1 px of A
936        let mut rgba = original.clone();
937        node.process_cpu(&mut rgba, 1, 1);
938        assert_eq!(rgba, original, "size mismatch must be a no-op");
939    }
940
941    #[test]
942    fn dissolve_with_an_empty_mask_should_be_clip_a() {
943        let node = DissolveTransitionNode::new(vec![0u8; 4], vec![210u8, 40, 130, 55], 1, 1);
944        let a = vec![10u8, 200, 30, 255];
945        let mut rgba = a.clone();
946        node.process_cpu(&mut rgba, 1, 1);
947        assert_eq!(rgba, a, "an unset mask must leave clip A");
948    }
949
950    #[test]
951    fn dissolve_with_a_full_mask_should_be_clip_b() {
952        let b = vec![210u8, 40, 130, 55];
953        let node = DissolveTransitionNode::new(vec![255u8; 4], b.clone(), 1, 1);
954        let mut rgba = vec![10u8, 200, 30, 255];
955        node.process_cpu(&mut rgba, 1, 1);
956        assert_eq!(rgba, b, "a set mask must reveal clip B");
957    }
958
959    #[test]
960    fn dissolve_should_follow_the_mask_pixel_for_pixel() {
961        // The property that separates this node from `FadeTransitionNode`: it *selects*,
962        // so every pixel stays fully one clip or the other, and which one is the mask's
963        // decision rather than the node's. Pinning the selection exactly (not "about
964        // half") is what makes the node reusable for `FFmpeg`'s own dissolve, whose mask
965        // is computed elsewhere precisely because it cannot be recomputed here.
966        let (w, h) = (8u32, 4u32);
967        let n = (w * h) as usize;
968        let a: Vec<u8> = [0u8, 0, 0, 255].repeat(n);
969        let b: Vec<u8> = [255u8, 255, 255, 255].repeat(n);
970        // An irregular pattern, so a node that ignored the mask and thresholded on its
971        // own could not coincidentally agree.
972        let mut mask = vec![0u8; n * 4];
973        for i in 0..n {
974            if i % 3 == 0 {
975                mask[i * 4..i * 4 + 4].fill(255);
976            }
977        }
978        let node = DissolveTransitionNode::new(mask, b, w, h);
979        let mut rgba = a.clone();
980        node.process_cpu(&mut rgba, w, h);
981        for (i, px) in rgba.as_chunks::<4>().0.iter().enumerate() {
982            let want = if i % 3 == 0 { 255 } else { 0 };
983            assert_eq!(px[0], want, "pixel {i} must follow the mask");
984        }
985    }
986
987    #[test]
988    fn dissolve_size_mismatch_should_leave_rgba_unchanged() {
989        let node = DissolveTransitionNode::new(vec![255u8; 8], vec![200u8; 8], 2, 1);
990        let original = vec![10u8, 200, 30, 255];
991        let mut rgba = original.clone();
992        node.process_cpu(&mut rgba, 1, 1);
993        assert_eq!(rgba, original, "size mismatch must be a no-op");
994    }
995
996    #[test]
997    fn dissolve_mask_size_mismatch_should_leave_rgba_unchanged() {
998        // Clip B is the right size but the mask is not: still a no-op rather than a
999        // partially-applied frame.
1000        let node = DissolveTransitionNode::new(vec![255u8; 8], vec![200u8; 4], 1, 1);
1001        let original = vec![10u8, 200, 30, 255];
1002        let mut rgba = original.clone();
1003        node.process_cpu(&mut rgba, 1, 1);
1004        assert_eq!(rgba, original, "a mask size mismatch must be a no-op");
1005    }
1006
1007    #[test]
1008    fn dip_progress_zero_should_be_clip_a() {
1009        let b = vec![200u8, 200, 200, 255];
1010        let node = DipToColorNode::new(0.0, [0.0, 0.0, 0.0], b, 1, 1);
1011        let a = vec![10u8, 20, 30, 255];
1012        let mut rgba = a.clone();
1013        node.process_cpu(&mut rgba, 1, 1);
1014        assert_eq!(rgba, a, "progress=0 must output clip A");
1015    }
1016
1017    #[test]
1018    fn dip_at_half_should_follow_ffmpegs_phased_curve() {
1019        // The midpoint is *not* the solid frame -- that is the linear dip this replaced.
1020        // With `FFmpeg`'s curve at progress 0.5 (so its own progress is 0.5 too):
1021        //   s1 = smoothstep(0.8, 1, 0.5) = 0            -> leaving  = bg = 0
1022        //   s2 = smoothstep(0.2, 1, 0.5) = 0.31640625   -> arriving = 200 * 0.68359 = 136.7
1023        //   out = 0 * 0.5 + 136.7 * 0.5                 = 68
1024        // Pinning the arithmetic rather than a vague "dark" keeps the phase honest: a
1025        // linear dip would read 0 here.
1026        let b = vec![200u8, 200, 200, 255];
1027        let node = DipToColorNode::new(0.5, [0.0, 0.0, 0.0], b, 1, 1);
1028        let mut rgba = vec![120u8, 130, 140, 255];
1029        node.process_cpu(&mut rgba, 1, 1);
1030        for (i, got) in rgba[0..3].iter().enumerate() {
1031            assert!(
1032                (i32::from(*got) - 68).abs() <= 1,
1033                "progress=0.5 must follow FFmpeg's phased curve (~68) at {i}, got {got}"
1034            );
1035        }
1036    }
1037
1038    #[test]
1039    fn dip_should_be_darkest_before_the_midpoint() {
1040        // `FFmpeg`'s `phase` of 0.2 puts the solid stretch in the first part of the
1041        // transition, not at the centre. Sampling across progress, the darkest frame must
1042        // land nearer 0.2 than 0.5 -- the property the old linear dip got backwards.
1043        let b = vec![200u8, 200, 200, 255];
1044        let darkest = (1..=9)
1045            .map(|i| {
1046                #[allow(clippy::cast_precision_loss)]
1047                let p = i as f32 / 10.0;
1048                let node = DipToColorNode::new(p, [0.0, 0.0, 0.0], b.clone(), 1, 1);
1049                let mut rgba = vec![120u8, 130, 140, 255];
1050                node.process_cpu(&mut rgba, 1, 1);
1051                (rgba[0], i)
1052            })
1053            .min()
1054            .map(|(_, i)| i)
1055            .expect("the sweep is non-empty");
1056        assert!(
1057            darkest <= 3,
1058            "the dip must bottom out in its first phase (<= 0.3), got progress 0.{darkest}"
1059        );
1060    }
1061
1062    #[test]
1063    fn dip_progress_one_should_be_clip_b() {
1064        let b = vec![200u8, 210, 220, 255];
1065        let node = DipToColorNode::new(1.0, [0.0, 0.0, 0.0], b.clone(), 1, 1);
1066        let mut rgba = vec![10u8, 20, 30, 255];
1067        node.process_cpu(&mut rgba, 1, 1);
1068        for (got, want) in rgba.iter().zip(b.iter()) {
1069            assert!(
1070                (i32::from(*got) - i32::from(*want)).abs() <= 1,
1071                "progress=1 must output clip B"
1072            );
1073        }
1074    }
1075
1076    #[test]
1077    fn dip_phase_two_size_mismatch_should_leave_rgba_unchanged() {
1078        let b = vec![200u8; 8]; // 2 px
1079        let node = DipToColorNode::new(0.75, [0.0, 0.0, 0.0], b, 2, 1);
1080        let original = vec![10u8, 20, 30, 255]; // 1 px
1081        let mut rgba = original.clone();
1082        node.process_cpu(&mut rgba, 1, 1);
1083        assert_eq!(rgba, original, "phase-2 size mismatch must be a no-op");
1084    }
1085}
1086
1087#[cfg(all(test, feature = "wgpu"))]
1088mod gpu_tests {
1089    use super::*;
1090    use crate::context::RenderContext;
1091    use crate::graph::RenderGraph;
1092    use std::sync::Arc;
1093
1094    fn ctx() -> Option<Arc<RenderContext>> {
1095        match futures::executor::block_on(RenderContext::init()) {
1096            Ok(ctx) => Some(Arc::new(ctx)),
1097            Err(_) => None,
1098        }
1099    }
1100
1101    #[test]
1102    fn transition_pipeline_should_be_compiled_once_across_frames() {
1103        let Some(ctx) = ctx() else {
1104            return;
1105        };
1106        // AC2 of #1726: a transition node carries the incoming clip's pixels, so it is
1107        // rebuilt every frame. Without the shared cache each rebuild recompiled the
1108        // shader -- about 5 ms, which a 30 fps preview cannot spend. Drive several
1109        // frames of one kind and assert the device compiled it once.
1110        let (w, h) = (8u32, 8u32);
1111        let n = (w * h) as usize;
1112        let a: Vec<u8> = [10u8, 20, 30, 255].repeat(n);
1113        let b: Vec<u8> = [200u8, 210, 220, 255].repeat(n);
1114        let before = ctx.transition_pipeline_count();
1115        for i in 0..5 {
1116            #[allow(clippy::cast_precision_loss)]
1117            let progress = i as f32 / 5.0;
1118            let out = RenderGraph::new(Arc::clone(&ctx))
1119                .push(FadeTransitionNode::new(progress, b.clone(), w, h))
1120                .process_gpu(&a, w, h)
1121                .expect("gpu fade");
1122            assert_eq!(out.len(), a.len());
1123        }
1124        assert_eq!(
1125            ctx.transition_pipeline_count() - before,
1126            1,
1127            "five frames of one kind must compile one pipeline, not five"
1128        );
1129    }
1130
1131    #[test]
1132    fn transition_pipelines_should_be_cached_per_kind() {
1133        let Some(ctx) = ctx() else {
1134            return;
1135        };
1136        // The other half: the cache is keyed by shader, so different kinds must not
1137        // collide onto one entry and render as each other.
1138        let (w, h) = (8u32, 8u32);
1139        let n = (w * h) as usize;
1140        let a: Vec<u8> = [10u8, 20, 30, 255].repeat(n);
1141        let b: Vec<u8> = [200u8, 210, 220, 255].repeat(n);
1142        let before = ctx.transition_pipeline_count();
1143        let _ = RenderGraph::new(Arc::clone(&ctx))
1144            .push(FadeTransitionNode::new(0.5, b.clone(), w, h))
1145            .process_gpu(&a, w, h)
1146            .expect("gpu fade");
1147        let _ = RenderGraph::new(Arc::clone(&ctx))
1148            .push(WipeTransitionNode::new(0.5, 0.0, 0.0, b, w, h))
1149            .process_gpu(&a, w, h)
1150            .expect("gpu wipe");
1151        assert_eq!(
1152            ctx.transition_pipeline_count() - before,
1153            2,
1154            "two kinds must hold two entries"
1155        );
1156    }
1157
1158    #[test]
1159    fn dissolve_gpu_should_follow_the_mask() {
1160        let Some(ctx) = ctx() else {
1161            return;
1162        };
1163        // The dissolve is the only transition that binds a third texture, so this is the
1164        // only test that exercises `build_pipeline`'s mask entry and `run_pass` binding
1165        // it. An irregular pattern, so a shader that ignored the mask could not agree by
1166        // coincidence; and `textureLoad`, not `textureSample`, so the linear sampler
1167        // cannot blur a per-pixel decision.
1168        let (w, h) = (8u32, 4u32);
1169        let n = (w * h) as usize;
1170        let a: Vec<u8> = [0u8, 0, 0, 255].repeat(n);
1171        let b: Vec<u8> = [255u8, 255, 255, 255].repeat(n);
1172        let mut mask = vec![0u8; n * 4];
1173        for i in 0..n {
1174            if i % 3 == 0 {
1175                mask[i * 4..i * 4 + 4].fill(255);
1176            }
1177        }
1178        let out = RenderGraph::new(Arc::clone(&ctx))
1179            .push(DissolveTransitionNode::new(mask, b, w, h))
1180            .process_gpu(&a, w, h)
1181            .expect("gpu dissolve");
1182        for (i, px) in out.as_chunks::<4>().0.iter().enumerate() {
1183            let want: u8 = if i % 3 == 0 { 255 } else { 0 };
1184            assert!(
1185                (i32::from(px[0]) - i32::from(want)).abs() <= 2,
1186                "GPU pixel {i} must follow the mask: got {} want {want}",
1187                px[0]
1188            );
1189        }
1190    }
1191
1192    #[test]
1193    fn wipe_gpu_should_land_on_ffmpegs_integer_column() {
1194        let Some(ctx) = ctx() else {
1195            return;
1196        };
1197        // The CPU mirror of this is `wipe_hard_edge_should_land_on_ffmpegs_integer_column`;
1198        // the shader has to agree column for column or the export and the preview drift.
1199        let a: Vec<u8> = (0..8).flat_map(|_| [10u8, 20, 30, 255]).collect();
1200        let b: Vec<u8> = (0..8).flat_map(|_| [200u8, 210, 220, 255]).collect();
1201        let out = RenderGraph::new(Arc::clone(&ctx))
1202            .push(WipeTransitionNode::new(0.5, 0.0, 0.0, b, 8, 1))
1203            .process_gpu(&a, 8, 1)
1204            .expect("gpu wipe");
1205        for x in 0..8 {
1206            let want: [u8; 3] = if x > 4 { [200, 210, 220] } else { [10, 20, 30] };
1207            for i in 0..3 {
1208                assert!(
1209                    (i32::from(out[x * 4 + i]) - i32::from(want[i])).abs() <= 2,
1210                    "GPU column {x} channel {i} at progress 0.5 (FFmpeg edge z=4)"
1211                );
1212            }
1213        }
1214    }
1215
1216    #[test]
1217    fn dip_gpu_at_half_should_match_the_cpu_curve() {
1218        let Some(ctx) = ctx() else {
1219            return;
1220        };
1221        let a = vec![120u8, 130, 140, 255];
1222        let b = vec![200u8, 200, 200, 255];
1223        let out = RenderGraph::new(Arc::clone(&ctx))
1224            .push(DipToColorNode::new(0.5, [0.0, 0.0, 0.0], b, 1, 1))
1225            .process_gpu(&a, 1, 1)
1226            .expect("gpu dip");
1227        // Same arithmetic as `dip_at_half_should_follow_ffmpegs_phased_curve`.
1228        for i in 0..3 {
1229            assert!(
1230                (i32::from(out[i]) - 68).abs() <= 2,
1231                "GPU dip at progress 0.5 must follow FFmpeg's curve (~68) at {i}, got {}",
1232                out[i]
1233            );
1234        }
1235    }
1236}