Skip to main content

wallr_core/renderer/
mod.rs

1use image::GenericImageView;
2use wgpu::util::DeviceExt;
3
4use crate::video::{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    /// Cached pipeline for a specific surface format. Created lazily.
19    pipeline: std::sync::Mutex<Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>>,
20    nv12_bind_group_layout: wgpu::BindGroupLayout,
21    nv12_pipeline: wgpu::RenderPipeline,
22}
23
24pub struct VideoTexture {
25    output: wgpu::Texture,
26    output_view: wgpu::TextureView,
27    effects_bind_group: wgpu::BindGroup,
28    luma: wgpu::Texture,
29    chroma: wgpu::Texture,
30    conversion_buffer: wgpu::Buffer,
31    conversion_bind_group: wgpu::BindGroup,
32    width: u32,
33    height: u32,
34}
35
36impl VideoTexture {
37    pub fn texture(&self) -> &wgpu::Texture {
38        &self.output
39    }
40
41    pub fn bind_group(&self) -> &wgpu::BindGroup {
42        &self.effects_bind_group
43    }
44}
45
46#[repr(C)]
47#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
48struct YuvConversion {
49    range: [f32; 4],
50    red: [f32; 4],
51    green: [f32; 4],
52    blue: [f32; 4],
53}
54
55impl YuvConversion {
56    fn new(color: YuvColorInfo) -> Self {
57        let range = match color.range {
58            YuvRange::Limited => [16.0 / 255.0, 255.0 / 219.0, 128.0 / 255.0, 255.0 / 224.0],
59            YuvRange::Full => [0.0, 1.0, 128.0 / 255.0, 1.0],
60        };
61        let (red_cr, green_cb, green_cr, blue_cb) = match color.matrix {
62            YuvMatrix::Bt601 => (1.402, -0.344_136, -0.714_136, 1.772),
63            YuvMatrix::Bt709 => (1.5748, -0.187_324, -0.468_124, 1.8556),
64            YuvMatrix::Bt2020 => (1.4746, -0.164_553, -0.571_353, 1.8814),
65        };
66        Self {
67            range,
68            red: [1.0, 0.0, red_cr, 0.0],
69            green: [1.0, green_cb, green_cr, 0.0],
70            blue: [1.0, blue_cb, 0.0, 0.0],
71        }
72    }
73}
74
75/// Per-output uniform buffer and bind group. Each output gets its own so
76/// concurrent renders never race on shared GPU state.
77pub struct PerOutputUniforms {
78    pub buffer: wgpu::Buffer,
79    pub bind_group: wgpu::BindGroup,
80}
81
82#[repr(C)]
83#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
84pub struct Uniforms {
85    pub time: f32,
86    pub progress: f32,
87    pub effect_type: u32,
88    pub padding: u32,
89    pub resolution: [f32; 2],
90    pub image_resolution: [f32; 2],
91    pub old_image_resolution: [f32; 2],
92    pub param_a: f32,
93    pub param_b: f32,
94    pub param_c: f32,
95    pub param_d: f32,
96    pub origin: [f32; 2],
97    pub direction: [f32; 2],
98    pub easing: u32,
99    pub scaling_mode: u32,
100}
101
102impl Uniforms {
103    pub fn from_effect(effect: &crate::animation::EffectUniforms) -> Self {
104        Self {
105            time: effect.progress,
106            progress: effect.progress,
107            effect_type: effect.effect_type,
108            padding: 0,
109            resolution: [1920.0, 1080.0],
110            image_resolution: [1920.0, 1080.0],
111            old_image_resolution: [1920.0, 1080.0],
112            param_a: effect.param_a,
113            param_b: effect.param_b,
114            param_c: effect.param_c,
115            param_d: effect.param_d,
116            origin: effect.origin,
117            direction: effect.direction,
118            easing: effect.easing,
119            scaling_mode: 0,
120        }
121    }
122}
123
124impl Default for Uniforms {
125    fn default() -> Self {
126        Self {
127            time: 0.0,
128            progress: 0.0,
129            effect_type: 0,
130            padding: 0,
131            resolution: [1920.0, 1080.0],
132            image_resolution: [1920.0, 1080.0],
133            old_image_resolution: [1920.0, 1080.0],
134            param_a: 0.0,
135            param_b: 0.0,
136            param_c: 0.0,
137            param_d: 0.0,
138            origin: [0.5, 0.5],
139            direction: [0.0, 0.0],
140            easing: 3,
141            scaling_mode: 0,
142        }
143    }
144}
145
146impl Renderer {
147    pub async fn new() -> anyhow::Result<Self> {
148        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
149            backends: wgpu::Backends::all(),
150            ..Default::default()
151        });
152
153        let adapter = instance
154            .request_adapter(&wgpu::RequestAdapterOptions {
155                power_preference: wgpu::PowerPreference::HighPerformance,
156                compatible_surface: None,
157                force_fallback_adapter: false,
158            })
159            .await
160            .ok_or_else(|| anyhow::anyhow!("Failed to find suitable adapter"))?;
161
162        let adapter_limits = adapter.limits();
163        let required_limits = wgpu::Limits {
164            max_texture_dimension_2d: adapter_limits.max_texture_dimension_2d,
165            ..wgpu::Limits::default()
166        };
167
168        let (device, queue) = adapter
169            .request_device(
170                &wgpu::DeviceDescriptor {
171                    label: None,
172                    required_features: wgpu::Features::empty(),
173                    required_limits,
174                    memory_hints: wgpu::MemoryHints::default(),
175                },
176                None,
177            )
178            .await?;
179
180        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
181            label: Some("Effects Shader"),
182            source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(
183                crate::shader::EFFECTS_SHADER,
184            )),
185        });
186
187        let bind_group_layout_tex =
188            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
189                entries: &[
190                    wgpu::BindGroupLayoutEntry {
191                        binding: 0,
192                        visibility: wgpu::ShaderStages::FRAGMENT,
193                        ty: wgpu::BindingType::Texture {
194                            multisampled: false,
195                            view_dimension: wgpu::TextureViewDimension::D2,
196                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
197                        },
198                        count: None,
199                    },
200                    wgpu::BindGroupLayoutEntry {
201                        binding: 1,
202                        visibility: wgpu::ShaderStages::FRAGMENT,
203                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
204                        count: None,
205                    },
206                ],
207                label: Some("texture_bind_group_layout"),
208            });
209
210        let bind_group_layout_uni =
211            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
212                entries: &[wgpu::BindGroupLayoutEntry {
213                    binding: 0,
214                    visibility: wgpu::ShaderStages::FRAGMENT,
215                    ty: wgpu::BindingType::Buffer {
216                        ty: wgpu::BufferBindingType::Uniform,
217                        has_dynamic_offset: false,
218                        min_binding_size: None,
219                    },
220                    count: None,
221                }],
222                label: Some("uniform_bind_group_layout"),
223            });
224
225        let nv12_bind_group_layout =
226            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
227                label: Some("NV12 Conversion Bind Group Layout"),
228                entries: &[
229                    wgpu::BindGroupLayoutEntry {
230                        binding: 0,
231                        visibility: wgpu::ShaderStages::FRAGMENT,
232                        ty: wgpu::BindingType::Texture {
233                            multisampled: false,
234                            view_dimension: wgpu::TextureViewDimension::D2,
235                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
236                        },
237                        count: None,
238                    },
239                    wgpu::BindGroupLayoutEntry {
240                        binding: 1,
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: 2,
251                        visibility: wgpu::ShaderStages::FRAGMENT,
252                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
253                        count: None,
254                    },
255                    wgpu::BindGroupLayoutEntry {
256                        binding: 3,
257                        visibility: wgpu::ShaderStages::FRAGMENT,
258                        ty: wgpu::BindingType::Buffer {
259                            ty: wgpu::BufferBindingType::Uniform,
260                            has_dynamic_offset: false,
261                            min_binding_size: None,
262                        },
263                        count: None,
264                    },
265                ],
266            });
267
268        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
269            label: Some("Render Pipeline Layout"),
270            bind_group_layouts: &[
271                &bind_group_layout_tex,
272                &bind_group_layout_tex,
273                &bind_group_layout_uni,
274            ],
275            push_constant_ranges: &[],
276        });
277
278        let nv12_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
279            label: Some("NV12 to RGB Shader"),
280            source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(
281                crate::shader::NV12_TO_RGB_SHADER,
282            )),
283        });
284        let nv12_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
285            label: Some("NV12 Conversion Pipeline Layout"),
286            bind_group_layouts: &[&nv12_bind_group_layout],
287            push_constant_ranges: &[],
288        });
289        let nv12_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
290            label: Some("NV12 Conversion Pipeline"),
291            layout: Some(&nv12_pipeline_layout),
292            vertex: wgpu::VertexState {
293                module: &nv12_shader,
294                entry_point: Some("vs_main"),
295                buffers: &[],
296                compilation_options: wgpu::PipelineCompilationOptions::default(),
297            },
298            fragment: Some(wgpu::FragmentState {
299                module: &nv12_shader,
300                entry_point: Some("fs_main"),
301                targets: &[Some(wgpu::ColorTargetState {
302                    format: wgpu::TextureFormat::Rgba8UnormSrgb,
303                    blend: Some(wgpu::BlendState::REPLACE),
304                    write_mask: wgpu::ColorWrites::ALL,
305                })],
306                compilation_options: wgpu::PipelineCompilationOptions::default(),
307            }),
308            primitive: wgpu::PrimitiveState::default(),
309            depth_stencil: None,
310            multisample: wgpu::MultisampleState::default(),
311            multiview: None,
312            cache: None,
313        });
314
315        Ok(Self {
316            instance,
317            adapter,
318            device,
319            queue,
320            pipeline_layout,
321            shader,
322            bind_group_layout_tex,
323            bind_group_layout_uni,
324            pipeline: std::sync::Mutex::new(None),
325            nv12_bind_group_layout,
326            nv12_pipeline,
327        })
328    }
329
330    pub fn validate_static_decode(
331        width: u32,
332        height: u32,
333        decoded_bytes: u64,
334    ) -> anyhow::Result<()> {
335        anyhow::ensure!(
336            width > 0 && height > 0,
337            "decoded image dimensions must be non-zero, got {width}x{height}"
338        );
339        anyhow::ensure!(
340            decoded_bytes <= MAX_STATIC_DECODE_BYTES,
341            "decoded image for {width}x{height} requires approximately {:.1} MiB, exceeding the {:.0} MiB safety limit",
342            decoded_bytes as f64 / (1024.0 * 1024.0),
343            MAX_STATIC_DECODE_BYTES as f64 / (1024.0 * 1024.0)
344        );
345        Ok(())
346    }
347
348    /// Create a per-output uniform buffer and bind group so each output
349    /// renders with its own GPU state, eliminating cross-output races.
350    pub fn create_per_output_uniforms(&self) -> PerOutputUniforms {
351        let uniforms = Uniforms::default();
352        let buffer = self
353            .device
354            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
355                label: Some("Per-Output Uniform Buffer"),
356                contents: bytemuck::cast_slice(&[uniforms]),
357                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
358            });
359        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
360            layout: &self.bind_group_layout_uni,
361            entries: &[wgpu::BindGroupEntry {
362                binding: 0,
363                resource: buffer.as_entire_binding(),
364            }],
365            label: Some("per_output_uniform_bind_group"),
366        });
367        PerOutputUniforms { buffer, bind_group }
368    }
369
370    fn get_pipeline(&self, format: wgpu::TextureFormat) -> wgpu::RenderPipeline {
371        let mut cache = self.pipeline.lock().unwrap();
372        if let Some((cached_fmt, ref pipeline)) = *cache
373            && cached_fmt == format
374        {
375            return pipeline.clone();
376        }
377
378        let pipeline = self
379            .device
380            .create_render_pipeline(&wgpu::RenderPipelineDescriptor {
381                label: Some("Render Pipeline"),
382                layout: Some(&self.pipeline_layout),
383                vertex: wgpu::VertexState {
384                    module: &self.shader,
385                    entry_point: Some("vs_main"),
386                    buffers: &[],
387                    compilation_options: wgpu::PipelineCompilationOptions::default(),
388                },
389                fragment: Some(wgpu::FragmentState {
390                    module: &self.shader,
391                    entry_point: Some("fs_main"),
392                    targets: &[Some(wgpu::ColorTargetState {
393                        format,
394                        blend: Some(wgpu::BlendState::REPLACE),
395                        write_mask: wgpu::ColorWrites::ALL,
396                    })],
397                    compilation_options: wgpu::PipelineCompilationOptions::default(),
398                }),
399                primitive: wgpu::PrimitiveState {
400                    topology: wgpu::PrimitiveTopology::TriangleList,
401                    strip_index_format: None,
402                    front_face: wgpu::FrontFace::Ccw,
403                    cull_mode: None, // Don't cull — fullscreen quad
404                    polygon_mode: wgpu::PolygonMode::Fill,
405                    unclipped_depth: false,
406                    conservative: false,
407                },
408                depth_stencil: None,
409                multisample: wgpu::MultisampleState {
410                    count: 1,
411                    mask: !0,
412                    alpha_to_coverage_enabled: false,
413                },
414                multiview: None,
415                cache: None,
416            });
417
418        *cache = Some((format, pipeline.clone()));
419        pipeline
420    }
421
422    pub fn create_texture(
423        &self,
424        width: u32,
425        height: u32,
426    ) -> anyhow::Result<(wgpu::Texture, wgpu::BindGroup)> {
427        validate_texture_dimensions(width, height, self.device.limits().max_texture_dimension_2d)?;
428        validate_texture_memory(width, height, 4)?;
429        let size = wgpu::Extent3d {
430            width,
431            height,
432            depth_or_array_layers: 1,
433        };
434
435        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
436            label: None,
437            size,
438            mip_level_count: 1,
439            sample_count: 1,
440            dimension: wgpu::TextureDimension::D2,
441            format: wgpu::TextureFormat::Rgba8UnormSrgb,
442            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
443            view_formats: &[],
444        });
445
446        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
447        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
448            address_mode_u: wgpu::AddressMode::ClampToEdge,
449            address_mode_v: wgpu::AddressMode::ClampToEdge,
450            address_mode_w: wgpu::AddressMode::ClampToEdge,
451            mag_filter: wgpu::FilterMode::Linear,
452            min_filter: wgpu::FilterMode::Nearest,
453            mipmap_filter: wgpu::FilterMode::Nearest,
454            ..Default::default()
455        });
456        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
457            layout: &self.bind_group_layout_tex,
458            entries: &[
459                wgpu::BindGroupEntry {
460                    binding: 0,
461                    resource: wgpu::BindingResource::TextureView(&view),
462                },
463                wgpu::BindGroupEntry {
464                    binding: 1,
465                    resource: wgpu::BindingResource::Sampler(&sampler),
466                },
467            ],
468            label: None,
469        });
470
471        Ok((texture, bind_group))
472    }
473
474    pub fn update_texture(&self, texture: &wgpu::Texture, rgba: &[u8], width: u32, height: u32) {
475        self.queue.write_texture(
476            wgpu::TexelCopyTextureInfo {
477                texture,
478                mip_level: 0,
479                origin: wgpu::Origin3d::ZERO,
480                aspect: wgpu::TextureAspect::All,
481            },
482            rgba,
483            wgpu::TexelCopyBufferLayout {
484                offset: 0,
485                bytes_per_row: Some(4 * width),
486                rows_per_image: Some(height),
487            },
488            wgpu::Extent3d {
489                width,
490                height,
491                depth_or_array_layers: 1,
492            },
493        );
494    }
495
496    pub fn create_video_texture(&self, width: u32, height: u32) -> anyhow::Result<VideoTexture> {
497        self.validate_video_texture(width, height)?;
498        let plane_texture = |label, size, format| {
499            self.device.create_texture(&wgpu::TextureDescriptor {
500                label: Some(label),
501                size,
502                mip_level_count: 1,
503                sample_count: 1,
504                dimension: wgpu::TextureDimension::D2,
505                format,
506                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
507                view_formats: &[],
508            })
509        };
510        let luma = plane_texture(
511            "Video NV12 Luma",
512            wgpu::Extent3d {
513                width,
514                height,
515                depth_or_array_layers: 1,
516            },
517            wgpu::TextureFormat::R8Unorm,
518        );
519        let chroma = plane_texture(
520            "Video NV12 Chroma",
521            wgpu::Extent3d {
522                width: width.div_ceil(2),
523                height: height.div_ceil(2),
524                depth_or_array_layers: 1,
525            },
526            wgpu::TextureFormat::Rg8Unorm,
527        );
528        let output = self.device.create_texture(&wgpu::TextureDescriptor {
529            label: Some("Video RGB Output"),
530            size: wgpu::Extent3d {
531                width,
532                height,
533                depth_or_array_layers: 1,
534            },
535            mip_level_count: 1,
536            sample_count: 1,
537            dimension: wgpu::TextureDimension::D2,
538            format: wgpu::TextureFormat::Rgba8UnormSrgb,
539            usage: wgpu::TextureUsages::TEXTURE_BINDING
540                | wgpu::TextureUsages::COPY_DST
541                | wgpu::TextureUsages::RENDER_ATTACHMENT,
542            view_formats: &[],
543        });
544        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
545            label: Some("Video Plane Sampler"),
546            address_mode_u: wgpu::AddressMode::ClampToEdge,
547            address_mode_v: wgpu::AddressMode::ClampToEdge,
548            address_mode_w: wgpu::AddressMode::ClampToEdge,
549            mag_filter: wgpu::FilterMode::Linear,
550            min_filter: wgpu::FilterMode::Linear,
551            mipmap_filter: wgpu::FilterMode::Nearest,
552            ..Default::default()
553        });
554        let output_sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
555            label: Some("Video Output Sampler"),
556            address_mode_u: wgpu::AddressMode::ClampToEdge,
557            address_mode_v: wgpu::AddressMode::ClampToEdge,
558            address_mode_w: wgpu::AddressMode::ClampToEdge,
559            mag_filter: wgpu::FilterMode::Linear,
560            min_filter: wgpu::FilterMode::Nearest,
561            mipmap_filter: wgpu::FilterMode::Nearest,
562            ..Default::default()
563        });
564        let conversion = YuvConversion::new(YuvColorInfo {
565            matrix: YuvMatrix::Bt709,
566            range: YuvRange::Limited,
567        });
568        let conversion_buffer = self
569            .device
570            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
571                label: Some("Video YUV Conversion Uniform"),
572                contents: bytemuck::bytes_of(&conversion),
573                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
574            });
575        let luma_view = luma.create_view(&wgpu::TextureViewDescriptor::default());
576        let chroma_view = chroma.create_view(&wgpu::TextureViewDescriptor::default());
577        let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
578        let conversion_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
579            label: Some("Video NV12 Conversion Bind Group"),
580            layout: &self.nv12_bind_group_layout,
581            entries: &[
582                wgpu::BindGroupEntry {
583                    binding: 0,
584                    resource: wgpu::BindingResource::TextureView(&luma_view),
585                },
586                wgpu::BindGroupEntry {
587                    binding: 1,
588                    resource: wgpu::BindingResource::TextureView(&chroma_view),
589                },
590                wgpu::BindGroupEntry {
591                    binding: 2,
592                    resource: wgpu::BindingResource::Sampler(&sampler),
593                },
594                wgpu::BindGroupEntry {
595                    binding: 3,
596                    resource: conversion_buffer.as_entire_binding(),
597                },
598            ],
599        });
600        let effects_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
601            label: Some("Video Effects Bind Group"),
602            layout: &self.bind_group_layout_tex,
603            entries: &[
604                wgpu::BindGroupEntry {
605                    binding: 0,
606                    resource: wgpu::BindingResource::TextureView(&output_view),
607                },
608                wgpu::BindGroupEntry {
609                    binding: 1,
610                    resource: wgpu::BindingResource::Sampler(&output_sampler),
611                },
612            ],
613        });
614
615        Ok(VideoTexture {
616            output,
617            output_view,
618            effects_bind_group,
619            luma,
620            chroma,
621            conversion_buffer,
622            conversion_bind_group,
623            width,
624            height,
625        })
626    }
627
628    pub fn validate_video_texture(&self, width: u32, height: u32) -> anyhow::Result<()> {
629        validate_texture_dimensions(width, height, self.device.limits().max_texture_dimension_2d)?;
630        // RGBA output plus NV12 luma/chroma planes use about 5.5 bytes per
631        // pixel. Round up so all allocations stay within a conservative cap.
632        validate_texture_memory(width, height, 6)
633    }
634
635    pub fn update_video_texture(
636        &self,
637        texture: &VideoTexture,
638        frame: &VideoFrameData,
639    ) -> anyhow::Result<()> {
640        match frame {
641            VideoFrameData::Rgba(rgba) => {
642                anyhow::ensure!(
643                    rgba.len() == texture.width as usize * texture.height as usize * 4,
644                    "invalid RGBA video frame size"
645                );
646                self.update_texture(&texture.output, rgba, texture.width, texture.height);
647            }
648            VideoFrameData::Nv12 {
649                y_plane,
650                uv_plane,
651                color,
652            } => {
653                let chroma_width = texture.width.div_ceil(2);
654                let chroma_height = texture.height.div_ceil(2);
655                anyhow::ensure!(
656                    y_plane.len() == texture.width as usize * texture.height as usize,
657                    "invalid NV12 luma plane size"
658                );
659                anyhow::ensure!(
660                    uv_plane.len() == (chroma_width * chroma_height * 2) as usize,
661                    "invalid NV12 chroma plane size"
662                );
663                self.queue.write_texture(
664                    texture.luma.as_image_copy(),
665                    y_plane,
666                    wgpu::TexelCopyBufferLayout {
667                        offset: 0,
668                        bytes_per_row: Some(texture.width),
669                        rows_per_image: Some(texture.height),
670                    },
671                    wgpu::Extent3d {
672                        width: texture.width,
673                        height: texture.height,
674                        depth_or_array_layers: 1,
675                    },
676                );
677                self.queue.write_texture(
678                    texture.chroma.as_image_copy(),
679                    uv_plane,
680                    wgpu::TexelCopyBufferLayout {
681                        offset: 0,
682                        bytes_per_row: Some(chroma_width * 2),
683                        rows_per_image: Some(chroma_height),
684                    },
685                    wgpu::Extent3d {
686                        width: chroma_width,
687                        height: chroma_height,
688                        depth_or_array_layers: 1,
689                    },
690                );
691                self.queue.write_buffer(
692                    &texture.conversion_buffer,
693                    0,
694                    bytemuck::bytes_of(&YuvConversion::new(*color)),
695                );
696
697                let mut encoder =
698                    self.device
699                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
700                            label: Some("NV12 Conversion Encoder"),
701                        });
702                {
703                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
704                        label: Some("NV12 Conversion Pass"),
705                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
706                            view: &texture.output_view,
707                            resolve_target: None,
708                            ops: wgpu::Operations {
709                                load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
710                                store: wgpu::StoreOp::Store,
711                            },
712                        })],
713                        depth_stencil_attachment: None,
714                        occlusion_query_set: None,
715                        timestamp_writes: None,
716                    });
717                    pass.set_pipeline(&self.nv12_pipeline);
718                    pass.set_bind_group(0, &texture.conversion_bind_group, &[]);
719                    pass.draw(0..3, 0..1);
720                }
721                self.queue.submit([encoder.finish()]);
722            }
723        }
724        Ok(())
725    }
726
727    pub fn load_texture(
728        &self,
729        image: &image::DynamicImage,
730        output_width: u32,
731        output_height: u32,
732        scaling_mode: u32,
733    ) -> anyhow::Result<(wgpu::Texture, wgpu::BindGroup, u32, u32)> {
734        let (width, height) = prepared_image_dimensions(
735            image.width(),
736            image.height(),
737            output_width,
738            output_height,
739            scaling_mode,
740            self.device.limits().max_texture_dimension_2d,
741        );
742        let rgba = if (width, height) == image.dimensions() {
743            image.to_rgba8()
744        } else {
745            image
746                .resize_exact(width, height, image::imageops::FilterType::Lanczos3)
747                .to_rgba8()
748        };
749        let (texture, bind_group) = self.create_texture(width, height)?;
750        self.update_texture(&texture, &rgba, width, height);
751        Ok((texture, bind_group, width, height))
752    }
753
754    pub fn update_uniforms(&self, buffer: &wgpu::Buffer, uniforms: Uniforms) {
755        self.queue
756            .write_buffer(buffer, 0, bytemuck::cast_slice(&[uniforms]));
757    }
758
759    pub fn render_frame(
760        &self,
761        request: FrameRequest,
762        per_output: &PerOutputUniforms,
763    ) -> anyhow::Result<FrameStatus> {
764        let FrameRequest {
765            surface,
766            format,
767            bg_bind,
768            new_bind,
769            effect,
770            width,
771            height,
772            img_width,
773            img_height,
774            old_img_width,
775            old_img_height,
776            scaling_mode,
777        } = request;
778        let mut uniforms = Uniforms::from_effect(effect);
779        uniforms.resolution = [width as f32, height as f32];
780        uniforms.image_resolution = [img_width as f32, img_height as f32];
781        uniforms.old_image_resolution = [old_img_width as f32, old_img_height as f32];
782        uniforms.scaling_mode = scaling_mode;
783        self.update_uniforms(&per_output.buffer, uniforms);
784
785        let pipeline = self.get_pipeline(format);
786        // `get_current_texture` blocks until the compositor presents (Fifo),
787        // which paces rendering to the refresh rate. A stalled compositor
788        // parks this call, which is safe because transition loops always run
789        // on detached tasks that never block the daemon's IPC loop.
790        let output = match surface.get_current_texture() {
791            Ok(texture) => texture,
792            Err(wgpu::SurfaceError::Timeout) => return Ok(FrameStatus::TimedOut),
793            Err(wgpu::SurfaceError::Outdated) => return Ok(FrameStatus::Outdated),
794            Err(wgpu::SurfaceError::Lost) => return Ok(FrameStatus::Lost),
795            Err(err) => {
796                return Err(anyhow::anyhow!(
797                    "failed to acquire swapchain texture: {err:?}"
798                ));
799            }
800        };
801        let view = output
802            .texture
803            .create_view(&wgpu::TextureViewDescriptor::default());
804
805        let mut encoder = self
806            .device
807            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
808                label: Some("Render Encoder"),
809            });
810
811        {
812            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
813                label: Some("Wallpaper Render Pass"),
814                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
815                    view: &view,
816                    resolve_target: None,
817                    ops: wgpu::Operations {
818                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
819                        store: wgpu::StoreOp::Store,
820                    },
821                })],
822                depth_stencil_attachment: None,
823                occlusion_query_set: None,
824                timestamp_writes: None,
825            });
826
827            render_pass.set_pipeline(&pipeline);
828            render_pass.set_bind_group(0, bg_bind, &[]);
829            render_pass.set_bind_group(1, new_bind, &[]);
830            render_pass.set_bind_group(2, &per_output.bind_group, &[]);
831            // The vertex shader generates a fullscreen triangle from vertex_index 0..3
832            render_pass.draw(0..3, 0..1);
833        }
834
835        self.queue.submit(std::iter::once(encoder.finish()));
836        output.present();
837
838        Ok(FrameStatus::Presented)
839    }
840}
841
842/// Whether a frame was presented or the surface needs recovery.
843#[derive(Debug, Clone, Copy, PartialEq, Eq)]
844pub enum FrameStatus {
845    Presented,
846    TimedOut,
847    Outdated,
848    Lost,
849}
850
851fn validate_texture_dimensions(width: u32, height: u32, limit: u32) -> anyhow::Result<()> {
852    anyhow::ensure!(
853        width > 0 && height > 0,
854        "texture dimensions must be non-zero, got {width}x{height}"
855    );
856    anyhow::ensure!(
857        width <= limit && height <= limit,
858        "texture dimensions {width}x{height} exceed the GPU limit of {limit}"
859    );
860    Ok(())
861}
862
863fn validate_texture_memory(width: u32, height: u32, bytes_per_pixel: u64) -> anyhow::Result<()> {
864    validate_image_memory(
865        width,
866        height,
867        bytes_per_pixel,
868        MAX_TEXTURE_BYTES,
869        "texture allocation",
870    )
871}
872
873fn validate_image_memory(
874    width: u32,
875    height: u32,
876    bytes_per_pixel: u64,
877    limit: u64,
878    label: &str,
879) -> anyhow::Result<()> {
880    let bytes = u64::from(width)
881        .checked_mul(u64::from(height))
882        .and_then(|pixels| pixels.checked_mul(bytes_per_pixel))
883        .ok_or_else(|| anyhow::anyhow!("{label} size overflow for {width}x{height}"))?;
884    anyhow::ensure!(
885        bytes <= limit,
886        "{label} for {width}x{height} requires approximately {:.1} MiB, exceeding the {:.0} MiB safety limit",
887        bytes as f64 / (1024.0 * 1024.0),
888        limit as f64 / (1024.0 * 1024.0)
889    );
890    Ok(())
891}
892
893fn prepared_image_dimensions(
894    image_width: u32,
895    image_height: u32,
896    output_width: u32,
897    output_height: u32,
898    scaling_mode: u32,
899    texture_limit: u32,
900) -> (u32, u32) {
901    if image_width == 0 || image_height == 0 {
902        return (image_width, image_height);
903    }
904
905    let limit_scale = (texture_limit as f64 / image_width as f64)
906        .min(texture_limit as f64 / image_height as f64)
907        .min(1.0);
908    let output_scale = match scaling_mode {
909        // Fill retains enough pixels to cover the output; fit retains enough
910        // to fit inside it. The shader still owns the final crop/letterbox.
911        0 if output_width > 0 && output_height > 0 => (output_width as f64 / image_width as f64)
912            .max(output_height as f64 / image_height as f64)
913            .min(1.0),
914        1 if output_width > 0 && output_height > 0 => (output_width as f64 / image_width as f64)
915            .min(output_height as f64 / image_height as f64)
916            .min(1.0),
917        2 if output_width > 0 && output_height > 0 => {
918            return (
919                image_width.min(output_width).min(texture_limit).max(1),
920                image_height.min(output_height).min(texture_limit).max(1),
921            );
922        }
923        // Center and tile have pixel-sensitive semantics. Reject an impossible
924        // native texture later rather than silently changing its apparent size.
925        _ => return (image_width, image_height),
926    };
927    let scale = limit_scale.min(output_scale);
928
929    (
930        ((image_width as f64 * scale).round() as u32).max(1),
931        ((image_height as f64 * scale).round() as u32).max(1),
932    )
933}
934
935/// Everything needed to present one transition frame to a surface.
936pub struct FrameRequest<'a> {
937    pub surface: &'a wgpu::Surface<'a>,
938    pub format: wgpu::TextureFormat,
939    /// Outgoing wallpaper frame, normally the daemon's previous wallpaper.
940    pub bg_bind: &'a wgpu::BindGroup,
941    /// New wallpaper.
942    pub new_bind: &'a wgpu::BindGroup,
943    /// Effect uniforms for this frame (progress, params, origin, easing...).
944    pub effect: &'a crate::animation::EffectUniforms,
945    pub width: u32,
946    pub height: u32,
947    pub img_width: u32,
948    pub img_height: u32,
949    pub old_img_width: u32,
950    pub old_img_height: u32,
951    /// Scaling mode: 0=Fill, 1=Fit, 2=Stretch, 3=Center, 4=Tile.
952    pub scaling_mode: u32,
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    #[test]
960    fn selects_yuv_conversion_coefficients_and_range() {
961        let limited_709 = YuvConversion::new(YuvColorInfo {
962            matrix: YuvMatrix::Bt709,
963            range: YuvRange::Limited,
964        });
965        assert_eq!(limited_709.red, [1.0, 0.0, 1.5748, 0.0]);
966        assert_eq!(limited_709.green, [1.0, -0.187_324, -0.468_124, 0.0]);
967        assert_eq!(limited_709.range[0], 16.0 / 255.0);
968        assert_eq!(limited_709.range[1], 255.0 / 219.0);
969
970        let full_2020 = YuvConversion::new(YuvColorInfo {
971            matrix: YuvMatrix::Bt2020,
972            range: YuvRange::Full,
973        });
974        assert_eq!(full_2020.red, [1.0, 0.0, 1.4746, 0.0]);
975        assert_eq!(full_2020.blue, [1.0, 1.8814, 0.0, 0.0]);
976        assert_eq!(full_2020.range, [0.0, 1.0, 128.0 / 255.0, 1.0]);
977    }
978
979    #[test]
980    fn validates_texture_dimensions_before_wgpu() {
981        assert!(validate_texture_dimensions(8192, 8192, 8192).is_ok());
982        assert!(validate_texture_dimensions(0, 1080, 8192).is_err());
983        assert!(validate_texture_dimensions(8193, 1080, 8192).is_err());
984        assert!(validate_texture_dimensions(1920, 8193, 8192).is_err());
985        assert!(validate_texture_memory(7_680, 4_320, 6).is_ok());
986        assert!(validate_texture_memory(16_384, 16_384, 4).is_err());
987        assert!(Renderer::validate_static_decode(11_322, 6_192, 210_304_512).is_ok());
988        assert!(Renderer::validate_static_decode(32_768, 32_768, u64::MAX).is_err());
989    }
990
991    #[test]
992    fn fill_reduces_large_images_to_cover_the_output() {
993        assert_eq!(
994            prepared_image_dimensions(11_322, 6_192, 3_840, 2_160, 0, 32_768),
995            (3_950, 2_160)
996        );
997    }
998
999    #[test]
1000    fn fit_preserves_aspect_ratio_without_upscaling() {
1001        assert_eq!(
1002            prepared_image_dimensions(11_322, 6_192, 3_840, 2_160, 1, 32_768),
1003            (3_840, 2_100)
1004        );
1005        assert_eq!(
1006            prepared_image_dimensions(1_920, 1_080, 3_840, 2_160, 1, 32_768),
1007            (1_920, 1_080)
1008        );
1009    }
1010
1011    #[test]
1012    fn pixel_sensitive_modes_preserve_native_dimensions() {
1013        assert_eq!(
1014            prepared_image_dimensions(11_322, 6_192, 3_840, 2_160, 3, 8_192),
1015            (11_322, 6_192)
1016        );
1017        assert_eq!(
1018            prepared_image_dimensions(3_840, 2_160, 2_560, 1_440, 4, 8_192),
1019            (3_840, 2_160)
1020        );
1021    }
1022
1023    #[test]
1024    fn stretch_does_not_upscale_small_images() {
1025        assert_eq!(
1026            prepared_image_dimensions(1, 1, 3_840, 2_160, 2, 32_768),
1027            (1, 1)
1028        );
1029        assert_eq!(
1030            prepared_image_dimensions(5_000, 1_000, 3_840, 2_160, 2, 32_768),
1031            (3_840, 1_000)
1032        );
1033    }
1034}