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 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 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 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
100pub 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 let (instance, adapter) = 'select: {
178 #[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 #[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 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 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 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, 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 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 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 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 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
828pub 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 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 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 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#[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 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 _ => 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
1055pub struct FrameRequest<'a> {
1057 pub surface: &'a wgpu::Surface<'a>,
1058 pub format: wgpu::TextureFormat,
1059 pub bg_bind: &'a wgpu::BindGroup,
1061 pub new_bind: &'a wgpu::BindGroup,
1063 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 pub scaling_mode: u32,
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use super::*;
1078
1079 #[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 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 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 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 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 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 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 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}