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