Skip to main content

wallr_core/renderer/
mod.rs

1use image::GenericImageView;
2use wgpu::util::DeviceExt;
3
4use crate::video::{GpuSelection, VideoFrameData, YuvColorInfo, YuvMatrix, YuvRange};
5
6const MAX_TEXTURE_BYTES: u64 = 256 * 1024 * 1024;
7const MAX_STATIC_DECODE_BYTES: u64 = 512 * 1024 * 1024;
8
9pub struct Renderer {
10    pub instance: wgpu::Instance,
11    pub adapter: wgpu::Adapter,
12    pub device: wgpu::Device,
13    pub queue: wgpu::Queue,
14    pub bind_group_layout_tex: wgpu::BindGroupLayout,
15    pub bind_group_layout_uni: wgpu::BindGroupLayout,
16    pipeline_layout: wgpu::PipelineLayout,
17    shader: wgpu::ShaderModule,
18    /// Lazily created pipelines keyed by surface format. Key space is the
19    /// finite `wgpu::TextureFormat` enum; in practice 1-2 entries (one per
20    /// distinct surface format across outputs). Never created in the frame
21    /// hot path more than once per format.
22    pipeline:
23        std::sync::Mutex<std::collections::HashMap<wgpu::TextureFormat, wgpu::RenderPipeline>>,
24    nv12_bind_group_layout: wgpu::BindGroupLayout,
25    nv12_pipeline: wgpu::RenderPipeline,
26    /// Shared samplers. Previously a new sampler was created per texture
27    /// upload; samplers are immutable and cheap to share, so one per use
28    /// lives as long as the renderer.
29    static_sampler: wgpu::Sampler,
30    video_plane_sampler: wgpu::Sampler,
31    video_output_sampler: wgpu::Sampler,
32}
33
34pub struct VideoTexture {
35    output: wgpu::Texture,
36    output_view: wgpu::TextureView,
37    effects_bind_group: wgpu::BindGroup,
38    luma: wgpu::Texture,
39    chroma: wgpu::Texture,
40    conversion_buffer: wgpu::Buffer,
41    conversion_bind_group: wgpu::BindGroup,
42    width: u32,
43    height: u32,
44}
45
46impl VideoTexture {
47    pub fn texture(&self) -> &wgpu::Texture {
48        &self.output
49    }
50
51    pub fn bind_group(&self) -> &wgpu::BindGroup {
52        &self.effects_bind_group
53    }
54
55    pub fn width(&self) -> u32 {
56        self.width
57    }
58
59    pub fn height(&self) -> u32 {
60        self.height
61    }
62
63    /// True when an incoming frame can reuse the allocated Y/UV/output
64    /// textures, bind groups, and conversion buffer instead of recreating
65    /// GPU resources. Only a real resolution change requires recreation.
66    pub fn matches_dimensions(&self, width: u32, height: u32) -> bool {
67        self.width == width && self.height == height
68    }
69}
70
71#[repr(C)]
72#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
73struct YuvConversion {
74    range: [f32; 4],
75    red: [f32; 4],
76    green: [f32; 4],
77    blue: [f32; 4],
78}
79
80impl YuvConversion {
81    fn new(color: YuvColorInfo) -> Self {
82        let range = match color.range {
83            YuvRange::Limited => [16.0 / 255.0, 255.0 / 219.0, 128.0 / 255.0, 255.0 / 224.0],
84            YuvRange::Full => [0.0, 1.0, 128.0 / 255.0, 1.0],
85        };
86        let (red_cr, green_cb, green_cr, blue_cb) = match color.matrix {
87            YuvMatrix::Bt601 => (1.402, -0.344_136, -0.714_136, 1.772),
88            YuvMatrix::Bt709 => (1.5748, -0.187_324, -0.468_124, 1.8556),
89            YuvMatrix::Bt2020 => (1.4746, -0.164_553, -0.571_353, 1.8814),
90        };
91        Self {
92            range,
93            red: [1.0, 0.0, red_cr, 0.0],
94            green: [1.0, green_cb, green_cr, 0.0],
95            blue: [1.0, blue_cb, 0.0, 0.0],
96        }
97    }
98}
99
100/// Per-output uniform buffer and bind group. Each output gets its own so
101/// concurrent renders never race on shared GPU state.
102pub struct PerOutputUniforms {
103    pub buffer: wgpu::Buffer,
104    pub bind_group: wgpu::BindGroup,
105}
106
107#[repr(C)]
108#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
109pub struct Uniforms {
110    pub time: f32,
111    pub progress: f32,
112    pub effect_type: u32,
113    pub padding: u32,
114    pub resolution: [f32; 2],
115    pub image_resolution: [f32; 2],
116    pub old_image_resolution: [f32; 2],
117    pub param_a: f32,
118    pub param_b: f32,
119    pub param_c: f32,
120    pub param_d: f32,
121    pub origin: [f32; 2],
122    pub direction: [f32; 2],
123    pub easing: u32,
124    pub scaling_mode: u32,
125}
126
127impl Uniforms {
128    pub fn from_effect(effect: &crate::animation::EffectUniforms) -> Self {
129        Self {
130            time: effect.progress,
131            progress: effect.progress,
132            effect_type: effect.effect_type,
133            padding: 0,
134            resolution: [1920.0, 1080.0],
135            image_resolution: [1920.0, 1080.0],
136            old_image_resolution: [1920.0, 1080.0],
137            param_a: effect.param_a,
138            param_b: effect.param_b,
139            param_c: effect.param_c,
140            param_d: effect.param_d,
141            origin: effect.origin,
142            direction: effect.direction,
143            easing: effect.easing,
144            scaling_mode: 0,
145        }
146    }
147}
148
149impl Default for Uniforms {
150    fn default() -> Self {
151        Self {
152            time: 0.0,
153            progress: 0.0,
154            effect_type: 0,
155            padding: 0,
156            resolution: [1920.0, 1080.0],
157            image_resolution: [1920.0, 1080.0],
158            old_image_resolution: [1920.0, 1080.0],
159            param_a: 0.0,
160            param_b: 0.0,
161            param_c: 0.0,
162            param_d: 0.0,
163            origin: [0.5, 0.5],
164            direction: [0.0, 0.0],
165            easing: 3,
166            scaling_mode: 0,
167        }
168    }
169}
170
171impl Renderer {
172    pub async fn new(preference: &GpuSelection) -> anyhow::Result<Self> {
173        // A Wayland wallpaper daemon on Linux only needs the native Linux GPU
174        // backends. `Backends::all()` also probes browser/mobile/Apple
175        // backends that cannot produce a Wayland surface here, increasing
176        // startup work and sometimes loading unnecessary driver state.
177        let (instance, adapter) = 'select: {
178            // Try Vulkan first without initializing the GL backend: probing
179            // GL loads its whole driver stack (~15 ms measured) for a
180            // fallback most systems never need.
181            #[cfg(target_os = "linux")]
182            {
183                let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
184                    backends: wgpu::Backends::VULKAN,
185                    ..wgpu::InstanceDescriptor::new_without_display_handle()
186                });
187                if let Ok(adapter) = crate::video::select_adapter(&instance, preference).await {
188                    break 'select (instance, adapter);
189                }
190                tracing::info!("No Vulkan adapter found, falling back to GL");
191            }
192            // Keep Vulkan as the primary path and OpenGL as a compatibility
193            // fallback (plus everything elsewhere).
194            #[cfg(target_os = "linux")]
195            let backends = wgpu::Backends::VULKAN | wgpu::Backends::GL;
196            #[cfg(not(target_os = "linux"))]
197            let backends = wgpu::Backends::all();
198            let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
199                backends,
200                ..wgpu::InstanceDescriptor::new_without_display_handle()
201            });
202            let adapter = crate::video::select_adapter(&instance, preference)
203                .await
204                .map_err(|e| anyhow::anyhow!(e))?;
205            (instance, adapter)
206        };
207
208        let adapter_limits = adapter.limits();
209        let required_limits = wgpu::Limits {
210            max_texture_dimension_2d: adapter_limits.max_texture_dimension_2d,
211            ..wgpu::Limits::default()
212        };
213
214        let (device, queue) = adapter
215            .request_device(&wgpu::DeviceDescriptor {
216                label: None,
217                required_features: wgpu::Features::empty(),
218                required_limits,
219                experimental_features: wgpu::ExperimentalFeatures::disabled(),
220                // Wallr is a persistent background process, not a game:
221                // prefer smaller allocations and lower residency over
222                // speculative throughput. Frame pacing remains governed
223                // by FIFO presentation and the compositor.
224                memory_hints: wgpu::MemoryHints::MemoryUsage,
225                trace: wgpu::Trace::default(),
226            })
227            .await?;
228
229        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
230            label: Some("Effects Shader"),
231            source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(
232                crate::shader::EFFECTS_SHADER,
233            )),
234        });
235
236        let bind_group_layout_tex =
237            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
238                entries: &[
239                    wgpu::BindGroupLayoutEntry {
240                        binding: 0,
241                        visibility: wgpu::ShaderStages::FRAGMENT,
242                        ty: wgpu::BindingType::Texture {
243                            multisampled: false,
244                            view_dimension: wgpu::TextureViewDimension::D2,
245                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
246                        },
247                        count: None,
248                    },
249                    wgpu::BindGroupLayoutEntry {
250                        binding: 1,
251                        visibility: wgpu::ShaderStages::FRAGMENT,
252                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
253                        count: None,
254                    },
255                ],
256                label: Some("texture_bind_group_layout"),
257            });
258
259        let bind_group_layout_uni =
260            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
261                entries: &[wgpu::BindGroupLayoutEntry {
262                    binding: 0,
263                    visibility: wgpu::ShaderStages::FRAGMENT,
264                    ty: wgpu::BindingType::Buffer {
265                        ty: wgpu::BufferBindingType::Uniform,
266                        has_dynamic_offset: false,
267                        min_binding_size: None,
268                    },
269                    count: None,
270                }],
271                label: Some("uniform_bind_group_layout"),
272            });
273
274        let nv12_bind_group_layout =
275            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
276                label: Some("NV12 Conversion Bind Group Layout"),
277                entries: &[
278                    wgpu::BindGroupLayoutEntry {
279                        binding: 0,
280                        visibility: wgpu::ShaderStages::FRAGMENT,
281                        ty: wgpu::BindingType::Texture {
282                            multisampled: false,
283                            view_dimension: wgpu::TextureViewDimension::D2,
284                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
285                        },
286                        count: None,
287                    },
288                    wgpu::BindGroupLayoutEntry {
289                        binding: 1,
290                        visibility: wgpu::ShaderStages::FRAGMENT,
291                        ty: wgpu::BindingType::Texture {
292                            multisampled: false,
293                            view_dimension: wgpu::TextureViewDimension::D2,
294                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
295                        },
296                        count: None,
297                    },
298                    wgpu::BindGroupLayoutEntry {
299                        binding: 2,
300                        visibility: wgpu::ShaderStages::FRAGMENT,
301                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
302                        count: None,
303                    },
304                    wgpu::BindGroupLayoutEntry {
305                        binding: 3,
306                        visibility: wgpu::ShaderStages::FRAGMENT,
307                        ty: wgpu::BindingType::Buffer {
308                            ty: wgpu::BufferBindingType::Uniform,
309                            has_dynamic_offset: false,
310                            min_binding_size: None,
311                        },
312                        count: None,
313                    },
314                ],
315            });
316
317        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
318            label: Some("Render Pipeline Layout"),
319            bind_group_layouts: &[
320                Some(&bind_group_layout_tex),
321                Some(&bind_group_layout_tex),
322                Some(&bind_group_layout_uni),
323            ],
324            immediate_size: 0,
325        });
326
327        let nv12_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
328            label: Some("NV12 to RGB Shader"),
329            source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(
330                crate::shader::NV12_TO_RGB_SHADER,
331            )),
332        });
333        let nv12_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
334            label: Some("NV12 Conversion Pipeline Layout"),
335            bind_group_layouts: &[Some(&nv12_bind_group_layout)],
336            immediate_size: 0,
337        });
338        let nv12_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
339            label: Some("NV12 Conversion Pipeline"),
340            layout: Some(&nv12_pipeline_layout),
341            vertex: wgpu::VertexState {
342                module: &nv12_shader,
343                entry_point: Some("vs_main"),
344                buffers: &[],
345                compilation_options: wgpu::PipelineCompilationOptions::default(),
346            },
347            fragment: Some(wgpu::FragmentState {
348                module: &nv12_shader,
349                entry_point: Some("fs_main"),
350                targets: &[Some(wgpu::ColorTargetState {
351                    format: wgpu::TextureFormat::Rgba8UnormSrgb,
352                    blend: Some(wgpu::BlendState::REPLACE),
353                    write_mask: wgpu::ColorWrites::ALL,
354                })],
355                compilation_options: wgpu::PipelineCompilationOptions::default(),
356            }),
357            primitive: wgpu::PrimitiveState::default(),
358            depth_stencil: None,
359            multisample: wgpu::MultisampleState::default(),
360            multiview_mask: None,
361            cache: None,
362        });
363
364        let static_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
365            label: Some("wallr static sampler"),
366            address_mode_u: wgpu::AddressMode::ClampToEdge,
367            address_mode_v: wgpu::AddressMode::ClampToEdge,
368            address_mode_w: wgpu::AddressMode::ClampToEdge,
369            mag_filter: wgpu::FilterMode::Linear,
370            min_filter: wgpu::FilterMode::Linear,
371            mipmap_filter: wgpu::MipmapFilterMode::Linear,
372            ..Default::default()
373        });
374        let video_plane_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
375            label: Some("Video Plane Sampler"),
376            address_mode_u: wgpu::AddressMode::ClampToEdge,
377            address_mode_v: wgpu::AddressMode::ClampToEdge,
378            address_mode_w: wgpu::AddressMode::ClampToEdge,
379            mag_filter: wgpu::FilterMode::Linear,
380            min_filter: wgpu::FilterMode::Linear,
381            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
382            ..Default::default()
383        });
384        let video_output_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
385            label: Some("Video Output Sampler"),
386            address_mode_u: wgpu::AddressMode::ClampToEdge,
387            address_mode_v: wgpu::AddressMode::ClampToEdge,
388            address_mode_w: wgpu::AddressMode::ClampToEdge,
389            mag_filter: wgpu::FilterMode::Linear,
390            min_filter: wgpu::FilterMode::Linear,
391            mipmap_filter: wgpu::MipmapFilterMode::Linear,
392            ..Default::default()
393        });
394
395        Ok(Self {
396            instance,
397            adapter,
398            device,
399            queue,
400            pipeline_layout,
401            shader,
402            bind_group_layout_tex,
403            bind_group_layout_uni,
404            pipeline: std::sync::Mutex::new(std::collections::HashMap::new()),
405            nv12_bind_group_layout,
406            nv12_pipeline,
407            static_sampler,
408            video_plane_sampler,
409            video_output_sampler,
410        })
411    }
412
413    pub fn validate_static_decode(
414        width: u32,
415        height: u32,
416        decoded_bytes: u64,
417    ) -> anyhow::Result<()> {
418        anyhow::ensure!(
419            width > 0 && height > 0,
420            "decoded image dimensions must be non-zero, got {width}x{height}"
421        );
422        anyhow::ensure!(
423            decoded_bytes <= MAX_STATIC_DECODE_BYTES,
424            "decoded image for {width}x{height} requires approximately {:.1} MiB, exceeding the {:.0} MiB safety limit",
425            decoded_bytes as f64 / (1024.0 * 1024.0),
426            MAX_STATIC_DECODE_BYTES as f64 / (1024.0 * 1024.0)
427        );
428        Ok(())
429    }
430
431    /// Create a per-output uniform buffer and bind group so each output
432    /// renders with its own GPU state, eliminating cross-output races.
433    pub fn create_per_output_uniforms(&self) -> PerOutputUniforms {
434        let uniforms = Uniforms::default();
435        let buffer = self
436            .device
437            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
438                label: Some("Per-Output Uniform Buffer"),
439                contents: bytemuck::cast_slice(&[uniforms]),
440                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
441            });
442        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
443            layout: &self.bind_group_layout_uni,
444            entries: &[wgpu::BindGroupEntry {
445                binding: 0,
446                resource: buffer.as_entire_binding(),
447            }],
448            label: Some("per_output_uniform_bind_group"),
449        });
450        PerOutputUniforms { buffer, bind_group }
451    }
452
453    fn get_pipeline(&self, format: wgpu::TextureFormat) -> wgpu::RenderPipeline {
454        // Fast path: reuse an already-created pipeline for this format.
455        // Only the first frame per format pays for pipeline creation, and
456        // creation never happens more than once per distinct format.
457        if let Ok(cache) = self.pipeline.lock().as_deref()
458            && let Some(pipeline) = cache.get(&format)
459        {
460            return pipeline.clone();
461        }
462
463        let pipeline = self
464            .device
465            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
466                label: Some("Render Pipeline"),
467                layout: Some(&self.pipeline_layout),
468                vertex: wgpu::VertexState {
469                    module: &self.shader,
470                    entry_point: Some("vs_main"),
471                    buffers: &[],
472                    compilation_options: wgpu::PipelineCompilationOptions::default(),
473                },
474                fragment: Some(wgpu::FragmentState {
475                    module: &self.shader,
476                    entry_point: Some("fs_main"),
477                    targets: &[Some(wgpu::ColorTargetState {
478                        format,
479                        blend: Some(wgpu::BlendState::REPLACE),
480                        write_mask: wgpu::ColorWrites::ALL,
481                    })],
482                    compilation_options: wgpu::PipelineCompilationOptions::default(),
483                }),
484                primitive: wgpu::PrimitiveState {
485                    topology: wgpu::PrimitiveTopology::TriangleList,
486                    strip_index_format: None,
487                    front_face: wgpu::FrontFace::Ccw,
488                    cull_mode: None, // Don't cull - fullscreen quad
489                    polygon_mode: wgpu::PolygonMode::Fill,
490                    unclipped_depth: false,
491                    conservative: false,
492                },
493                depth_stencil: None,
494                multisample: wgpu::MultisampleState {
495                    count: 1,
496                    mask: !0,
497                    alpha_to_coverage_enabled: false,
498                },
499                multiview_mask: None,
500                cache: None,
501            });
502
503        // Insert under lock; a concurrent first-frame for the same format
504        // may have inserted first, in which case keep the existing entry.
505        if let Ok(mut cache) = self.pipeline.lock() {
506            cache.entry(format).or_insert_with(|| pipeline.clone());
507            if let Some(existing) = cache.get(&format) {
508                return existing.clone();
509            }
510        }
511        pipeline
512    }
513
514    pub fn create_texture(
515        &self,
516        width: u32,
517        height: u32,
518    ) -> anyhow::Result<(wgpu::Texture, wgpu::BindGroup)> {
519        validate_texture_dimensions(width, height, self.device.limits().max_texture_dimension_2d)?;
520        validate_texture_memory(width, height, 4)?;
521        let size = wgpu::Extent3d {
522            width,
523            height,
524            depth_or_array_layers: 1,
525        };
526
527        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
528            label: None,
529            size,
530            mip_level_count: 1,
531            sample_count: 1,
532            dimension: wgpu::TextureDimension::D2,
533            format: wgpu::TextureFormat::Rgba8UnormSrgb,
534            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
535            view_formats: &[],
536        });
537
538        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
539        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
540            layout: &self.bind_group_layout_tex,
541            entries: &[
542                wgpu::BindGroupEntry {
543                    binding: 0,
544                    resource: wgpu::BindingResource::TextureView(&view),
545                },
546                wgpu::BindGroupEntry {
547                    binding: 1,
548                    resource: wgpu::BindingResource::Sampler(&self.static_sampler),
549                },
550            ],
551            label: None,
552        });
553
554        Ok((texture, bind_group))
555    }
556
557    pub fn update_texture(&self, texture: &wgpu::Texture, rgba: &[u8], width: u32, height: u32) {
558        self.queue.write_texture(
559            wgpu::TexelCopyTextureInfo {
560                texture,
561                mip_level: 0,
562                origin: wgpu::Origin3d::ZERO,
563                aspect: wgpu::TextureAspect::All,
564            },
565            rgba,
566            wgpu::TexelCopyBufferLayout {
567                offset: 0,
568                bytes_per_row: Some(4 * width),
569                rows_per_image: Some(height),
570            },
571            wgpu::Extent3d {
572                width,
573                height,
574                depth_or_array_layers: 1,
575            },
576        );
577    }
578
579    pub fn create_video_texture(&self, width: u32, height: u32) -> anyhow::Result<VideoTexture> {
580        self.validate_video_texture(width, height)?;
581        let plane_texture = |label, size, format| {
582            self.device.create_texture(&wgpu::TextureDescriptor {
583                label: Some(label),
584                size,
585                mip_level_count: 1,
586                sample_count: 1,
587                dimension: wgpu::TextureDimension::D2,
588                format,
589                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
590                view_formats: &[],
591            })
592        };
593        let luma = plane_texture(
594            "Video NV12 Luma",
595            wgpu::Extent3d {
596                width,
597                height,
598                depth_or_array_layers: 1,
599            },
600            wgpu::TextureFormat::R8Unorm,
601        );
602        let chroma = plane_texture(
603            "Video NV12 Chroma",
604            wgpu::Extent3d {
605                width: width.div_ceil(2),
606                height: height.div_ceil(2),
607                depth_or_array_layers: 1,
608            },
609            wgpu::TextureFormat::Rg8Unorm,
610        );
611        let output = self.device.create_texture(&wgpu::TextureDescriptor {
612            label: Some("Video RGB Output"),
613            size: wgpu::Extent3d {
614                width,
615                height,
616                depth_or_array_layers: 1,
617            },
618            mip_level_count: 1,
619            sample_count: 1,
620            dimension: wgpu::TextureDimension::D2,
621            format: wgpu::TextureFormat::Rgba8UnormSrgb,
622            usage: wgpu::TextureUsages::TEXTURE_BINDING
623                | wgpu::TextureUsages::COPY_DST
624                | wgpu::TextureUsages::RENDER_ATTACHMENT,
625            view_formats: &[],
626        });
627        let conversion = YuvConversion::new(YuvColorInfo {
628            matrix: YuvMatrix::Bt709,
629            range: YuvRange::Limited,
630        });
631        let conversion_buffer = self
632            .device
633            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
634                label: Some("Video YUV Conversion Uniform"),
635                contents: bytemuck::bytes_of(&conversion),
636                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
637            });
638        let luma_view = luma.create_view(&wgpu::TextureViewDescriptor::default());
639        let chroma_view = chroma.create_view(&wgpu::TextureViewDescriptor::default());
640        let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
641        let conversion_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
642            label: Some("Video NV12 Conversion Bind Group"),
643            layout: &self.nv12_bind_group_layout,
644            entries: &[
645                wgpu::BindGroupEntry {
646                    binding: 0,
647                    resource: wgpu::BindingResource::TextureView(&luma_view),
648                },
649                wgpu::BindGroupEntry {
650                    binding: 1,
651                    resource: wgpu::BindingResource::TextureView(&chroma_view),
652                },
653                wgpu::BindGroupEntry {
654                    binding: 2,
655                    resource: wgpu::BindingResource::Sampler(&self.video_plane_sampler),
656                },
657                wgpu::BindGroupEntry {
658                    binding: 3,
659                    resource: conversion_buffer.as_entire_binding(),
660                },
661            ],
662        });
663        let effects_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
664            label: Some("Video Effects Bind Group"),
665            layout: &self.bind_group_layout_tex,
666            entries: &[
667                wgpu::BindGroupEntry {
668                    binding: 0,
669                    resource: wgpu::BindingResource::TextureView(&output_view),
670                },
671                wgpu::BindGroupEntry {
672                    binding: 1,
673                    resource: wgpu::BindingResource::Sampler(&self.video_output_sampler),
674                },
675            ],
676        });
677
678        Ok(VideoTexture {
679            output,
680            output_view,
681            effects_bind_group,
682            luma,
683            chroma,
684            conversion_buffer,
685            conversion_bind_group,
686            width,
687            height,
688        })
689    }
690
691    pub fn validate_video_texture(&self, width: u32, height: u32) -> anyhow::Result<()> {
692        validate_texture_dimensions(width, height, self.device.limits().max_texture_dimension_2d)?;
693        // RGBA output plus NV12 luma/chroma planes use about 5.5 bytes per
694        // pixel. Round up so all allocations stay within a conservative cap.
695        validate_texture_memory(width, height, 6)
696    }
697
698    pub fn update_video_texture(
699        &self,
700        texture: &VideoTexture,
701        frame: &VideoFrameData,
702    ) -> anyhow::Result<()> {
703        match frame {
704            VideoFrameData::Rgba(rgba) => {
705                anyhow::ensure!(
706                    rgba.len() == texture.width as usize * texture.height as usize * 4,
707                    "invalid RGBA video frame size"
708                );
709                self.update_texture(&texture.output, rgba, texture.width, texture.height);
710            }
711            VideoFrameData::Nv12 {
712                y_plane,
713                uv_plane,
714                color,
715            } => {
716                let chroma_width = texture.width.div_ceil(2);
717                let chroma_height = texture.height.div_ceil(2);
718                anyhow::ensure!(
719                    y_plane.len() == texture.width as usize * texture.height as usize,
720                    "invalid NV12 luma plane size"
721                );
722                anyhow::ensure!(
723                    uv_plane.len() == (chroma_width * chroma_height * 2) as usize,
724                    "invalid NV12 chroma plane size"
725                );
726                self.queue.write_texture(
727                    texture.luma.as_image_copy(),
728                    y_plane,
729                    wgpu::TexelCopyBufferLayout {
730                        offset: 0,
731                        bytes_per_row: Some(texture.width),
732                        rows_per_image: Some(texture.height),
733                    },
734                    wgpu::Extent3d {
735                        width: texture.width,
736                        height: texture.height,
737                        depth_or_array_layers: 1,
738                    },
739                );
740                self.queue.write_texture(
741                    texture.chroma.as_image_copy(),
742                    uv_plane,
743                    wgpu::TexelCopyBufferLayout {
744                        offset: 0,
745                        bytes_per_row: Some(chroma_width * 2),
746                        rows_per_image: Some(chroma_height),
747                    },
748                    wgpu::Extent3d {
749                        width: chroma_width,
750                        height: chroma_height,
751                        depth_or_array_layers: 1,
752                    },
753                );
754                self.queue.write_buffer(
755                    &texture.conversion_buffer,
756                    0,
757                    bytemuck::bytes_of(&YuvConversion::new(*color)),
758                );
759
760                let mut encoder =
761                    self.device
762                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
763                            label: Some("NV12 Conversion Encoder"),
764                        });
765                {
766                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
767                        label: Some("NV12 Conversion Pass"),
768                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
769                            view: &texture.output_view,
770                            resolve_target: None,
771                            depth_slice: None,
772                            ops: wgpu::Operations {
773                                load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
774                                store: wgpu::StoreOp::Store,
775                            },
776                        })],
777                        depth_stencil_attachment: None,
778                        occlusion_query_set: None,
779                        timestamp_writes: None,
780                        multiview_mask: None,
781                    });
782                    pass.set_pipeline(&self.nv12_pipeline);
783                    pass.set_bind_group(0, &texture.conversion_bind_group, &[]);
784                    pass.draw(0..3, 0..1);
785                }
786                self.queue.submit([encoder.finish()]);
787            }
788        }
789        Ok(())
790    }
791
792    pub fn load_texture(
793        &self,
794        image: &image::DynamicImage,
795        output_width: u32,
796        output_height: u32,
797        scaling_mode: u32,
798    ) -> anyhow::Result<(wgpu::Texture, wgpu::BindGroup, u32, u32)> {
799        // Share the output-aware sizing policy and the SIMD resize path with
800        // the wl_shm fast path so large static uploads pay one efficient
801        // downscale instead of uploading a huge texture for the GPU to scale.
802        let (rgba, width, height) =
803            self.prepare_image_rgba(image, output_width, output_height, scaling_mode)?;
804        let (texture, bind_group) = self.create_texture(width, height)?;
805        self.update_texture(&texture, &rgba, width, height);
806        Ok((texture, bind_group, width, height))
807    }
808
809    /// Prepare pixels for a compositor-owned shared-memory buffer. This is
810    /// the static-image counterpart to `load_texture`: it applies the same
811    /// output-aware sizing policy without allocating a GPU texture.
812    pub fn prepare_image_rgba(
813        &self,
814        image: &image::DynamicImage,
815        output_width: u32,
816        output_height: u32,
817        scaling_mode: u32,
818    ) -> anyhow::Result<(image::RgbaImage, u32, u32)> {
819        prepare_image_rgba(
820            image,
821            output_width,
822            output_height,
823            scaling_mode,
824            self.device.limits().max_texture_dimension_2d,
825        )
826    }
827}
828
829/// Prepare compositor-owned static pixels without requiring a live GPU
830/// renderer. Keeping this operation independent is the first boundary for
831/// lazy GPU initialization: `wl_shm` outputs can decode and size static
832/// wallpapers before any wgpu device is created.
833pub fn prepare_image_rgba(
834    image: &image::DynamicImage,
835    output_width: u32,
836    output_height: u32,
837    scaling_mode: u32,
838    max_texture_dimension: u32,
839) -> anyhow::Result<(image::RgbaImage, u32, u32)> {
840    let (width, height) = prepared_image_dimensions(
841        image.width(),
842        image.height(),
843        output_width,
844        output_height,
845        scaling_mode,
846        max_texture_dimension,
847    );
848    let rgba = if (width, height) == image.dimensions() {
849        image.to_rgba8()
850    } else {
851        // Use the SIMD-capable resizer for the expensive downscale path. It
852        // preserves the existing Lanczos3 quality while avoiding the slower
853        // scalar imageops implementation for 4K-to-output conversions.
854        let source = image.to_rgba8();
855        let source_view = fast_image_resize::images::ImageRef::new(
856            source.width(),
857            source.height(),
858            source.as_raw(),
859            fast_image_resize::PixelType::U8x4,
860        )?;
861        let mut destination = fast_image_resize::images::Image::new(
862            width,
863            height,
864            fast_image_resize::PixelType::U8x4,
865        );
866        let mut resizer = fast_image_resize::Resizer::new();
867        resizer.resize(&source_view, &mut destination, None)?;
868        image::RgbaImage::from_raw(width, height, destination.into_vec())
869            .ok_or_else(|| anyhow::anyhow!("resizer returned an invalid RGBA buffer"))?
870    };
871    Ok((rgba, width, height))
872}
873
874impl Renderer {
875    pub fn update_uniforms(&self, buffer: &wgpu::Buffer, uniforms: Uniforms) {
876        self.queue
877            .write_buffer(buffer, 0, bytemuck::cast_slice(&[uniforms]));
878    }
879
880    pub fn render_frame(
881        &self,
882        request: FrameRequest,
883        per_output: &PerOutputUniforms,
884    ) -> anyhow::Result<FrameStatus> {
885        let FrameRequest {
886            surface,
887            format,
888            bg_bind,
889            new_bind,
890            effect,
891            width,
892            height,
893            img_width,
894            img_height,
895            old_img_width,
896            old_img_height,
897            scaling_mode,
898        } = request;
899        let mut uniforms = Uniforms::from_effect(effect);
900        uniforms.resolution = [width as f32, height as f32];
901        uniforms.image_resolution = [img_width as f32, img_height as f32];
902        uniforms.old_image_resolution = [old_img_width as f32, old_img_height as f32];
903        uniforms.scaling_mode = scaling_mode;
904        self.update_uniforms(&per_output.buffer, uniforms);
905
906        let pipeline = self.get_pipeline(format);
907        // `get_current_texture` blocks until the compositor presents (Fifo),
908        // which paces rendering to the refresh rate. A stalled compositor
909        // parks this call, which is safe because transition loops always run
910        // on detached tasks that never block the daemon's IPC loop.
911        let output = match surface.get_current_texture() {
912            wgpu::CurrentSurfaceTexture::Success(texture) => texture,
913            wgpu::CurrentSurfaceTexture::Suboptimal(texture) => texture,
914            wgpu::CurrentSurfaceTexture::Timeout => return Ok(FrameStatus::TimedOut),
915            wgpu::CurrentSurfaceTexture::Outdated => return Ok(FrameStatus::Outdated),
916            wgpu::CurrentSurfaceTexture::Lost => return Ok(FrameStatus::Lost),
917            wgpu::CurrentSurfaceTexture::Occluded => return Ok(FrameStatus::TimedOut),
918            wgpu::CurrentSurfaceTexture::Validation => {
919                return Err(anyhow::anyhow!(
920                    "failed to acquire swapchain texture: validation error"
921                ));
922            }
923        };
924        let view = output
925            .texture
926            .create_view(&wgpu::TextureViewDescriptor::default());
927
928        let mut encoder = self
929            .device
930            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
931                label: Some("Render Encoder"),
932            });
933
934        {
935            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
936                label: Some("Wallpaper Render Pass"),
937                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
938                    view: &view,
939                    resolve_target: None,
940                    depth_slice: None,
941                    ops: wgpu::Operations {
942                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
943                        store: wgpu::StoreOp::Store,
944                    },
945                })],
946                depth_stencil_attachment: None,
947                occlusion_query_set: None,
948                timestamp_writes: None,
949                multiview_mask: None,
950            });
951
952            render_pass.set_pipeline(&pipeline);
953            render_pass.set_bind_group(0, bg_bind, &[]);
954            render_pass.set_bind_group(1, new_bind, &[]);
955            render_pass.set_bind_group(2, &per_output.bind_group, &[]);
956            // The vertex shader generates a fullscreen triangle from vertex_index 0..3
957            render_pass.draw(0..3, 0..1);
958        }
959
960        self.queue.submit(std::iter::once(encoder.finish()));
961        self.queue.present(output);
962
963        Ok(FrameStatus::Presented)
964    }
965}
966
967/// Whether a frame was presented or the surface needs recovery.
968#[derive(Debug, Clone, Copy, PartialEq, Eq)]
969pub enum FrameStatus {
970    Presented,
971    TimedOut,
972    Outdated,
973    Lost,
974}
975
976fn validate_texture_dimensions(width: u32, height: u32, limit: u32) -> anyhow::Result<()> {
977    anyhow::ensure!(
978        width > 0 && height > 0,
979        "texture dimensions must be non-zero, got {width}x{height}"
980    );
981    anyhow::ensure!(
982        width <= limit && height <= limit,
983        "texture dimensions {width}x{height} exceed the GPU limit of {limit}"
984    );
985    Ok(())
986}
987
988fn validate_texture_memory(width: u32, height: u32, bytes_per_pixel: u64) -> anyhow::Result<()> {
989    validate_image_memory(
990        width,
991        height,
992        bytes_per_pixel,
993        MAX_TEXTURE_BYTES,
994        "texture allocation",
995    )
996}
997
998fn validate_image_memory(
999    width: u32,
1000    height: u32,
1001    bytes_per_pixel: u64,
1002    limit: u64,
1003    label: &str,
1004) -> anyhow::Result<()> {
1005    let bytes = u64::from(width)
1006        .checked_mul(u64::from(height))
1007        .and_then(|pixels| pixels.checked_mul(bytes_per_pixel))
1008        .ok_or_else(|| anyhow::anyhow!("{label} size overflow for {width}x{height}"))?;
1009    anyhow::ensure!(
1010        bytes <= limit,
1011        "{label} for {width}x{height} requires approximately {:.1} MiB, exceeding the {:.0} MiB safety limit",
1012        bytes as f64 / (1024.0 * 1024.0),
1013        limit as f64 / (1024.0 * 1024.0)
1014    );
1015    Ok(())
1016}
1017
1018fn prepared_image_dimensions(
1019    image_width: u32,
1020    image_height: u32,
1021    output_width: u32,
1022    output_height: u32,
1023    scaling_mode: u32,
1024    texture_limit: u32,
1025) -> (u32, u32) {
1026    if image_width == 0 || image_height == 0 {
1027        return (image_width, image_height);
1028    }
1029
1030    let limit_scale = (texture_limit as f64 / image_width as f64)
1031        .min(texture_limit as f64 / image_height as f64)
1032        .min(1.0);
1033    let output_scale = match scaling_mode {
1034        // Fill retains enough pixels to cover the output; fit retains enough
1035        // to fit inside it. The shader still owns the final crop/letterbox.
1036        0 if output_width > 0 && output_height > 0 => (output_width as f64 / image_width as f64)
1037            .max(output_height as f64 / image_height as f64)
1038            .min(1.0),
1039        1 if output_width > 0 && output_height > 0 => (output_width as f64 / image_width as f64)
1040            .min(output_height as f64 / image_height as f64)
1041            .min(1.0),
1042        2 if output_width > 0 && output_height > 0 => {
1043            return (
1044                image_width.min(output_width).min(texture_limit).max(1),
1045                image_height.min(output_height).min(texture_limit).max(1),
1046            );
1047        }
1048        // Center and tile have pixel-sensitive semantics. Reject an impossible
1049        // native texture later rather than silently changing its apparent size.
1050        _ => return (image_width, image_height),
1051    };
1052    let scale = limit_scale.min(output_scale);
1053
1054    (
1055        ((image_width as f64 * scale).round() as u32).max(1),
1056        ((image_height as f64 * scale).round() as u32).max(1),
1057    )
1058}
1059
1060/// Everything needed to present one transition frame to a surface.
1061pub struct FrameRequest<'a> {
1062    pub surface: &'a wgpu::Surface<'a>,
1063    pub format: wgpu::TextureFormat,
1064    /// Outgoing wallpaper frame, normally the daemon's previous wallpaper.
1065    pub bg_bind: &'a wgpu::BindGroup,
1066    /// New wallpaper.
1067    pub new_bind: &'a wgpu::BindGroup,
1068    /// Effect uniforms for this frame (progress, params, origin, easing...).
1069    pub effect: &'a crate::animation::EffectUniforms,
1070    pub width: u32,
1071    pub height: u32,
1072    pub img_width: u32,
1073    pub img_height: u32,
1074    pub old_img_width: u32,
1075    pub old_img_height: u32,
1076    /// Scaling mode: 0=Fill, 1=Fit, 2=Stretch, 3=Center, 4=Tile.
1077    pub scaling_mode: u32,
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083
1084    /// Renders a real fade transition offscreen (no compositor needed) and
1085    /// checks the blended pixels. Skips gracefully where no GPU exists.
1086    /// This exercises the full path the unit tests cannot: pipeline lookup,
1087    /// bind groups, uniform upload, and the WGSL blend for the renumbered
1088    /// effect arms.
1089    #[test]
1090    fn transition_renders_mid_fade_blend_offscreen() {
1091        let runtime = match tokio::runtime::Builder::new_current_thread()
1092            .enable_all()
1093            .build()
1094        {
1095            Ok(runtime) => runtime,
1096            Err(_) => return,
1097        };
1098        let renderer = match runtime.block_on(Renderer::new(&GpuSelection::Auto)) {
1099            Ok(renderer) => renderer,
1100            Err(_) => {
1101                eprintln!("SKIP: no GPU adapter for offscreen transition test");
1102                return;
1103            }
1104        };
1105        const SIZE: u32 = 64;
1106        let red = [255u8, 0, 0, 255].repeat((SIZE * SIZE) as usize);
1107        let blue = [0u8, 0, 255, 255].repeat((SIZE * SIZE) as usize);
1108        let (tex_old, bind_old) = renderer.create_texture(SIZE, SIZE).expect("old texture");
1109        let (tex_new, bind_new) = renderer.create_texture(SIZE, SIZE).expect("new texture");
1110        renderer.update_texture(&tex_old, &red, SIZE, SIZE);
1111        renderer.update_texture(&tex_new, &blue, SIZE, SIZE);
1112
1113        let per_output = renderer.create_per_output_uniforms();
1114        let render_at = |effect: &crate::animation::Effect, progress: f32| -> Vec<u8> {
1115            let effect = crate::animation::compute_effect_uniforms(effect, progress);
1116            let mut uniforms = Uniforms::from_effect(&effect);
1117            uniforms.resolution = [SIZE as f32, SIZE as f32];
1118            uniforms.image_resolution = [SIZE as f32, SIZE as f32];
1119            uniforms.old_image_resolution = [SIZE as f32, SIZE as f32];
1120            uniforms.scaling_mode = 0;
1121            renderer.update_uniforms(&per_output.buffer, uniforms);
1122
1123            let target = renderer.device.create_texture(&wgpu::TextureDescriptor {
1124                label: Some("offscreen transition target"),
1125                size: wgpu::Extent3d {
1126                    width: SIZE,
1127                    height: SIZE,
1128                    depth_or_array_layers: 1,
1129                },
1130                mip_level_count: 1,
1131                sample_count: 1,
1132                dimension: wgpu::TextureDimension::D2,
1133                format: wgpu::TextureFormat::Rgba8UnormSrgb,
1134                usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1135                view_formats: &[],
1136            });
1137            let view = target.create_view(&wgpu::TextureViewDescriptor::default());
1138            let pipeline = renderer.get_pipeline(wgpu::TextureFormat::Rgba8UnormSrgb);
1139            let mut encoder = renderer
1140                .device
1141                .create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
1142            {
1143                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1144                    label: Some("offscreen transition"),
1145                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1146                        view: &view,
1147                        resolve_target: None,
1148                        depth_slice: None,
1149                        ops: wgpu::Operations {
1150                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1151                            store: wgpu::StoreOp::Store,
1152                        },
1153                    })],
1154                    depth_stencil_attachment: None,
1155                    occlusion_query_set: None,
1156                    timestamp_writes: None,
1157                    multiview_mask: None,
1158                });
1159                pass.set_pipeline(&pipeline);
1160                pass.set_bind_group(0, &bind_old, &[]);
1161                pass.set_bind_group(1, &bind_new, &[]);
1162                pass.set_bind_group(2, &per_output.bind_group, &[]);
1163                pass.draw(0..3, 0..1);
1164            }
1165            let readback = renderer.device.create_buffer(&wgpu::BufferDescriptor {
1166                label: Some("offscreen readback"),
1167                size: (SIZE * SIZE * 4) as u64,
1168                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1169                mapped_at_creation: false,
1170            });
1171            encoder.copy_texture_to_buffer(
1172                wgpu::TexelCopyTextureInfo {
1173                    texture: &target,
1174                    mip_level: 0,
1175                    origin: wgpu::Origin3d::ZERO,
1176                    aspect: wgpu::TextureAspect::All,
1177                },
1178                wgpu::TexelCopyBufferInfo {
1179                    buffer: &readback,
1180                    layout: wgpu::TexelCopyBufferLayout {
1181                        offset: 0,
1182                        bytes_per_row: Some(SIZE * 4),
1183                        rows_per_image: Some(SIZE),
1184                    },
1185                },
1186                wgpu::Extent3d {
1187                    width: SIZE,
1188                    height: SIZE,
1189                    depth_or_array_layers: 1,
1190                },
1191            );
1192            renderer.queue.submit([encoder.finish()]);
1193            let slice = readback.slice(..);
1194            slice.map_async(wgpu::MapMode::Read, |_| {});
1195            renderer
1196                .device
1197                .poll(wgpu::PollType::wait_indefinitely())
1198                .ok();
1199            let pixels = {
1200                let mapped = slice.get_mapped_range().unwrap();
1201                mapped.to_vec()
1202            };
1203            readback.unmap();
1204            pixels
1205        };
1206        let pixel_at = |frame: &[u8], x: u32, y: u32| -> [u8; 4] {
1207            let i = ((y * SIZE + x) * 4) as usize;
1208            [frame[i], frame[i + 1], frame[i + 2], frame[i + 3]]
1209        };
1210
1211        let fade = crate::animation::Effect::Fade(crate::animation::FadeParams::default());
1212        // Fade endpoints are exact (no blending artifacts at start/end).
1213        let start = render_at(&fade, 0.0);
1214        let start = pixel_at(&start, SIZE / 2, SIZE / 2);
1215        assert!(start[0] > 240 && start[2] < 15, "start pixel: {start:?}");
1216        let end = render_at(&fade, 1.0);
1217        let end = pixel_at(&end, SIZE / 2, SIZE / 2);
1218        assert!(end[2] > 240 && end[0] < 15, "end pixel: {end:?}");
1219        // Mid-fade blends in linear light (sRGB textures decode on sample
1220        // and re-encode on store). The default Bezier easing runs ahead at
1221        // midpoint (awww's fast-start character), so blue leads red here
1222        // while their sum still matches a uniform mix.
1223        let mid = render_at(&fade, 0.5);
1224        let mid = pixel_at(&mid, SIZE / 2, SIZE / 2);
1225        assert!(
1226            (160..=215).contains(&mid[0])
1227                && (160..=215).contains(&mid[2])
1228                && mid[2] > mid[0]
1229                && mid[1] < 20,
1230            "mid pixel: {mid:?}"
1231        );
1232
1233        // A mid-wipe is sharp: well outside the feathered edge the pixels
1234        // are exactly old or new, with only a narrow blend band between.
1235        // (Default wipe enters from the LEFT, so the left edge is new/blue.)
1236        let wipe = crate::animation::Effect::Wipe(crate::animation::WipeParams::default());
1237        let frame = render_at(&wipe, 0.5);
1238        let left = pixel_at(&frame, 4, SIZE / 2);
1239        assert!(
1240            left[2] > 240 && left[0] < 15,
1241            "wipe revealed pixel: {left:?}"
1242        );
1243        let right = pixel_at(&frame, SIZE - 5, SIZE / 2);
1244        assert!(
1245            right[0] > 240 && right[2] < 15,
1246            "wipe unrevealed pixel: {right:?}"
1247        );
1248
1249        // Every cardinal enters from its named edge and must be fully shown
1250        // at p=1.0 (regression: a reversed threshold left the image mostly
1251        // old, then snapped).
1252        let at = |direction: crate::animation::WipeDirection, progress, x, y| {
1253            let e = crate::animation::Effect::Wipe(crate::animation::WipeParams {
1254                direction,
1255                ..crate::animation::WipeParams::default()
1256            });
1257            let f = render_at(&e, progress);
1258            pixel_at(&f, x, y)
1259        };
1260        let top = at(crate::animation::WipeDirection::Up, 1.0, SIZE / 2, 2);
1261        assert!(top[2] > 240 && top[0] < 15, "up wipe end pixel: {top:?}");
1262        let bottom = at(
1263            crate::animation::WipeDirection::Down,
1264            1.0,
1265            SIZE / 2,
1266            SIZE - 2,
1267        );
1268        assert!(
1269            bottom[2] > 240 && bottom[0] < 15,
1270            "down wipe end pixel: {bottom:?}"
1271        );
1272        let mid_top = at(
1273            crate::animation::WipeDirection::Down,
1274            0.5,
1275            SIZE / 2,
1276            SIZE - 2,
1277        );
1278        assert!(
1279            mid_top[2] > 240 && mid_top[0] < 15,
1280            "down wipe reveals bottom first: {mid_top:?}"
1281        );
1282        let far_top = at(crate::animation::WipeDirection::Down, 0.5, SIZE / 2, 2);
1283        assert!(
1284            far_top[0] > 240 && far_top[2] < 15,
1285            "down wipe keeps top old: {far_top:?}"
1286        );
1287
1288        // Sweep must be monotonic: a boundary column switches old->new once,
1289        // never back. This directly catches the old "new, old, new" symptom.
1290        // For a left-entering wipe, a column sweeps in left-to-right, so a
1291        // fixed middle column must be old early and new from halfway on.
1292        let mid_col = SIZE / 2;
1293        let sweep = [
1294            at(
1295                crate::animation::WipeDirection::Left,
1296                0.2,
1297                mid_col,
1298                SIZE / 2,
1299            ),
1300            at(
1301                crate::animation::WipeDirection::Left,
1302                0.4,
1303                mid_col,
1304                SIZE / 2,
1305            ),
1306            at(
1307                crate::animation::WipeDirection::Left,
1308                0.6,
1309                mid_col,
1310                SIZE / 2,
1311            ),
1312            at(
1313                crate::animation::WipeDirection::Left,
1314                0.8,
1315                mid_col,
1316                SIZE / 2,
1317            ),
1318        ];
1319        assert!(
1320            sweep[0][0] > 240 && sweep[0][2] < 15,
1321            "early old: {:?}",
1322            sweep[0]
1323        );
1324        assert!(
1325            sweep[1][0] > 240 && sweep[1][2] < 15,
1326            "early old: {:?}",
1327            sweep[1]
1328        );
1329        assert!(
1330            sweep[2][2] > 240 && sweep[2][0] < 15,
1331            "late new: {:?}",
1332            sweep[2]
1333        );
1334        assert!(
1335            sweep[3][2] > 240 && sweep[3][0] < 15,
1336            "late new: {:?}",
1337            sweep[3]
1338        );
1339        // Entry edges for the remaining cardinals.
1340        let right_entry = at(
1341            crate::animation::WipeDirection::Right,
1342            0.5,
1343            SIZE - 2,
1344            SIZE / 2,
1345        );
1346        assert!(
1347            right_entry[2] > 240 && right_entry[0] < 15,
1348            "right wipe reveals right first: {right_entry:?}"
1349        );
1350        let up_entry = at(crate::animation::WipeDirection::Up, 0.5, SIZE / 2, 2);
1351        assert!(
1352            up_entry[2] > 240 && up_entry[0] < 15,
1353            "up wipe reveals top first: {up_entry:?}"
1354        );
1355
1356        // A mid-grow is sharp too: the center is exactly new while the
1357        // far corner is still exactly old.
1358        let grow = crate::animation::Effect::Grow(crate::animation::GrowParams::default());
1359        let frame = render_at(&grow, 0.5);
1360        let center = pixel_at(&frame, SIZE / 2, SIZE / 2);
1361        assert!(
1362            center[2] > 240 && center[0] < 15,
1363            "grow revealed pixel: {center:?}"
1364        );
1365        let corner = pixel_at(&frame, 2, 2);
1366        assert!(
1367            corner[0] > 240 && corner[2] < 15,
1368            "grow unrevealed pixel: {corner:?}"
1369        );
1370    }
1371
1372    #[test]
1373    fn selects_yuv_conversion_coefficients_and_range() {
1374        let limited_709 = YuvConversion::new(YuvColorInfo {
1375            matrix: YuvMatrix::Bt709,
1376            range: YuvRange::Limited,
1377        });
1378        assert_eq!(limited_709.red, [1.0, 0.0, 1.5748, 0.0]);
1379        assert_eq!(limited_709.green, [1.0, -0.187_324, -0.468_124, 0.0]);
1380        assert_eq!(limited_709.range[0], 16.0 / 255.0);
1381        assert_eq!(limited_709.range[1], 255.0 / 219.0);
1382
1383        let full_2020 = YuvConversion::new(YuvColorInfo {
1384            matrix: YuvMatrix::Bt2020,
1385            range: YuvRange::Full,
1386        });
1387        assert_eq!(full_2020.red, [1.0, 0.0, 1.4746, 0.0]);
1388        assert_eq!(full_2020.blue, [1.0, 1.8814, 0.0, 0.0]);
1389        assert_eq!(full_2020.range, [0.0, 1.0, 128.0 / 255.0, 1.0]);
1390    }
1391
1392    #[test]
1393    fn validates_texture_dimensions_before_wgpu() {
1394        assert!(validate_texture_dimensions(8192, 8192, 8192).is_ok());
1395        assert!(validate_texture_dimensions(0, 1080, 8192).is_err());
1396        assert!(validate_texture_dimensions(8193, 1080, 8192).is_err());
1397        assert!(validate_texture_dimensions(1920, 8193, 8192).is_err());
1398        assert!(validate_texture_memory(7_680, 4_320, 6).is_ok());
1399        assert!(validate_texture_memory(16_384, 16_384, 4).is_err());
1400        assert!(Renderer::validate_static_decode(11_322, 6_192, 210_304_512).is_ok());
1401        assert!(Renderer::validate_static_decode(32_768, 32_768, u64::MAX).is_err());
1402    }
1403
1404    #[test]
1405    fn fill_reduces_large_images_to_cover_the_output() {
1406        assert_eq!(
1407            prepared_image_dimensions(11_322, 6_192, 3_840, 2_160, 0, 32_768),
1408            (3_950, 2_160)
1409        );
1410    }
1411
1412    #[test]
1413    fn fit_preserves_aspect_ratio_without_upscaling() {
1414        assert_eq!(
1415            prepared_image_dimensions(11_322, 6_192, 3_840, 2_160, 1, 32_768),
1416            (3_840, 2_100)
1417        );
1418        assert_eq!(
1419            prepared_image_dimensions(1_920, 1_080, 3_840, 2_160, 1, 32_768),
1420            (1_920, 1_080)
1421        );
1422    }
1423
1424    #[test]
1425    fn pixel_sensitive_modes_preserve_native_dimensions() {
1426        assert_eq!(
1427            prepared_image_dimensions(11_322, 6_192, 3_840, 2_160, 3, 8_192),
1428            (11_322, 6_192)
1429        );
1430        assert_eq!(
1431            prepared_image_dimensions(3_840, 2_160, 2_560, 1_440, 4, 8_192),
1432            (3_840, 2_160)
1433        );
1434    }
1435
1436    #[test]
1437    fn stretch_does_not_upscale_small_images() {
1438        assert_eq!(
1439            prepared_image_dimensions(1, 1, 3_840, 2_160, 2, 32_768),
1440            (1, 1)
1441        );
1442        assert_eq!(
1443            prepared_image_dimensions(5_000, 1_000, 3_840, 2_160, 2, 32_768),
1444            (3_840, 1_000)
1445        );
1446    }
1447}