Skip to main content

wallr_core/preview/
mod.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::Instant;
4
5use image::ImageDecoder;
6use tracing::info;
7use winit::application::ApplicationHandler;
8use winit::dpi::LogicalSize;
9use winit::event::WindowEvent;
10use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
11use winit::window::{Window, WindowId};
12
13use crate::animation::{Effect, compute_effect_uniforms};
14use crate::renderer::Renderer;
15
16#[derive(Debug, thiserror::Error)]
17pub enum PreviewError {
18    #[error("failed to open preview window: {0}")]
19    Window(String),
20}
21
22pub struct PreviewWindow {
23    pub target_path: PathBuf,
24    pub duration: std::time::Duration,
25    pub effect: Effect,
26}
27
28impl PreviewWindow {
29    pub fn new(target_path: PathBuf) -> Self {
30        Self {
31            target_path,
32            duration: std::time::Duration::from_millis(2000),
33            effect: Effect::Grow(crate::animation::GrowParams::default()),
34        }
35    }
36
37    pub async fn run(&self) -> Result<(), PreviewError> {
38        info!("Initializing preview mode for: {:?}", self.target_path);
39
40        let event_loop = EventLoop::new().map_err(|e| PreviewError::Window(e.to_string()))?;
41        event_loop.set_control_flow(ControlFlow::Poll);
42
43        let mut app = PreviewApp::new(self.target_path.clone(), self.duration, self.effect.clone());
44        event_loop
45            .run_app(&mut app)
46            .map_err(|e| PreviewError::Window(e.to_string()))?;
47        Ok(())
48    }
49}
50
51/// The daemon persists the path of the last applied wallpaper here.
52fn last_wallpaper_state() -> Option<PathBuf> {
53    let state_path = dirs::cache_dir()
54        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
55        .join("wallr/last_wallpaper");
56    std::fs::read_to_string(state_path)
57        .ok()
58        .map(|s| PathBuf::from(s.trim()))
59}
60
61/// Load the last applied wallpaper for use as the outgoing frame. Returns
62/// `None` when nothing was ever applied or when it is the same file as the
63/// incoming image (a same-to-same transition would show nothing).
64fn load_last_wallpaper(target: &std::path::Path) -> Option<image::DynamicImage> {
65    let last = last_wallpaper_state().filter(|p| p.exists())?;
66    let same = last.canonicalize().unwrap_or_else(|_| last.clone())
67        == target
68            .canonicalize()
69            .unwrap_or_else(|_| target.to_path_buf());
70    if same {
71        return None;
72    }
73    let decoder = image::ImageReader::open(&last).ok()?.into_decoder().ok()?;
74    let (width, height) = decoder.dimensions();
75    Renderer::validate_static_decode(width, height, decoder.total_bytes()).ok()?;
76    image::DynamicImage::from_decoder(decoder).ok()
77}
78
79struct PreviewApp {
80    target_path: PathBuf,
81    duration: std::time::Duration,
82    effect: Effect,
83    window: Option<Arc<Window>>,
84    renderer: Option<Renderer>,
85    surface: Option<wgpu::Surface<'static>>,
86    surface_format: Option<wgpu::TextureFormat>,
87    bg_bind: Option<wgpu::BindGroup>,
88    new_bind: Option<wgpu::BindGroup>,
89    _bg_tex: Option<wgpu::Texture>,
90    _new_tex: Option<wgpu::Texture>,
91    start: Option<Instant>,
92    img_size: (u32, u32),
93    old_img_size: (u32, u32),
94    animated: Option<crate::animated::AnimatedImage>,
95    play_tex: Option<wgpu::Texture>,
96    play_bind: Option<wgpu::BindGroup>,
97    shown_frame: usize,
98    video: Option<crate::video::VideoPlayback>,
99    video_texture: Option<crate::renderer::VideoTexture>,
100    video_size: (u32, u32),
101    per_output_uniforms: Option<crate::renderer::PerOutputUniforms>,
102}
103
104impl PreviewApp {
105    fn new(target_path: PathBuf, duration: std::time::Duration, effect: Effect) -> Self {
106        Self {
107            target_path,
108            duration,
109            effect,
110            window: None,
111            renderer: None,
112            surface: None,
113            surface_format: None,
114            bg_bind: None,
115            new_bind: None,
116            _bg_tex: None,
117            _new_tex: None,
118            start: None,
119            img_size: (1, 1),
120            old_img_size: (1, 1),
121            animated: None,
122            play_tex: None,
123            play_bind: None,
124            shown_frame: usize::MAX,
125            video: None,
126            video_texture: None,
127            video_size: (1, 1),
128            per_output_uniforms: None,
129        }
130    }
131
132    fn configure_surface(
133        &self,
134        renderer: &Renderer,
135        surface: &wgpu::Surface<'static>,
136        format: wgpu::TextureFormat,
137        width: u32,
138        height: u32,
139    ) {
140        surface.configure(
141            &renderer.device,
142            &wgpu::SurfaceConfiguration {
143                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
144                format,
145                width: width.max(1),
146                height: height.max(1),
147                present_mode: wgpu::PresentMode::AutoVsync,
148                alpha_mode: wgpu::CompositeAlphaMode::Auto,
149                view_formats: vec![],
150                desired_maximum_frame_latency: 2,
151            },
152        );
153    }
154}
155
156impl ApplicationHandler for PreviewApp {
157    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
158        if self.window.is_some() {
159            return;
160        }
161
162        let window = match event_loop.create_window(
163            Window::default_attributes()
164                .with_title("wallr preview")
165                .with_inner_size(LogicalSize::new(1280.0, 720.0)),
166        ) {
167            Ok(w) => Arc::new(w),
168            Err(e) => {
169                eprintln!("failed to create preview window: {e}");
170                event_loop.exit();
171                return;
172            }
173        };
174
175        let renderer = match pollster::block_on(Renderer::new()) {
176            Ok(r) => r,
177            Err(e) => {
178                eprintln!("renderer init failed: {e}");
179                event_loop.exit();
180                return;
181            }
182        };
183
184        let surface = match renderer.instance.create_surface(window.clone()) {
185            Ok(s) => s,
186            Err(e) => {
187                eprintln!("surface creation failed: {e}");
188                event_loop.exit();
189                return;
190            }
191        };
192
193        let caps = surface.get_capabilities(&renderer.adapter);
194        let format = caps
195            .formats
196            .iter()
197            .find(|f| f.is_srgb())
198            .copied()
199            .unwrap_or(caps.formats[0]);
200
201        let size = window.inner_size();
202        self.configure_surface(&renderer, &surface, format, size.width, size.height);
203
204        // Video preview: stream decoded frames directly into the preview
205        // window. Images and GIFs keep the transition-based preview path.
206        if crate::video::VideoDecoder::is_video_file(&self.target_path) {
207            let playback = crate::video::VideoPlayback::new();
208            match crate::video::VideoPlayback::prepare(
209                &self.target_path,
210                crate::video::HwAccel::from_config("auto"),
211                crate::config::VideoConfig::default().preload_frames,
212                std::time::Duration::from_millis(2000),
213                |metadata| {
214                    renderer
215                        .validate_video_texture(metadata.width, metadata.height)
216                        .map_err(crate::video::VideoError::GpuResourceCreation)
217                },
218            ) {
219                Ok(mut prepared) => {
220                    let meta = prepared.metadata().clone();
221                    let first = prepared.take_first_frame();
222                    let (w, h) = (meta.width, meta.height);
223                    let texture = match renderer.create_video_texture(w, h) {
224                        Ok(texture) => texture,
225                        Err(e) => {
226                            eprintln!("failed to create video texture: {e}");
227                            event_loop.exit();
228                            return;
229                        }
230                    };
231                    if let Some(frame) = first {
232                        if let Err(e) = renderer.update_video_texture(&texture, &frame.data) {
233                            eprintln!("failed to upload first video frame: {e}");
234                            event_loop.exit();
235                            return;
236                        }
237                    }
238                    playback.commit(prepared, 0);
239                    self.video = Some(playback);
240                    self.video_texture = Some(texture);
241                    self.video_size = (w, h);
242                    self.window = Some(window);
243                    self.per_output_uniforms = Some(renderer.create_per_output_uniforms());
244                    self.renderer = Some(renderer);
245                    self.surface = Some(surface);
246                    self.surface_format = Some(format);
247                    self.start = Some(Instant::now());
248                    return;
249                }
250                Err(e) => {
251                    eprintln!("failed to start video preview: {e}");
252                    event_loop.exit();
253                    return;
254                }
255            }
256        }
257
258        let img = match image::ImageReader::open(&self.target_path) {
259            Ok(reader) => match reader.into_decoder() {
260                Ok(decoder) => {
261                    let (width, height) = decoder.dimensions();
262                    if let Err(e) =
263                        Renderer::validate_static_decode(width, height, decoder.total_bytes())
264                    {
265                        eprintln!("failed to decode image: {e}");
266                        event_loop.exit();
267                        return;
268                    }
269                    match image::DynamicImage::from_decoder(decoder) {
270                        Ok(img) => img,
271                        Err(e) => {
272                            eprintln!("failed to decode image: {e}");
273                            event_loop.exit();
274                            return;
275                        }
276                    }
277                }
278                Err(e) => {
279                    eprintln!("failed to decode image: {e}");
280                    event_loop.exit();
281                    return;
282                }
283            },
284            Err(e) => {
285                eprintln!("failed to open image: {e}");
286                event_loop.exit();
287                return;
288            }
289        };
290
291        let (new_tex, new_bind, w, h) =
292            match renderer.load_texture(&img, size.width, size.height, 0) {
293                Ok(t) => t,
294                Err(e) => {
295                    eprintln!("failed to upload image: {e}");
296                    event_loop.exit();
297                    return;
298                }
299            };
300
301        // Fade in over the last applied wallpaper (persisted by the daemon),
302        // so the preview shows a real transition like on the desktop. Fall
303        // back to a solid black texture when nothing was ever applied, when
304        // the outgoing image cannot be loaded, or when it is the same file
305        // as the incoming one (a same-to-same loop would show nothing).
306        let (bg_tex, bg_bind, old_size) =
307            match load_last_wallpaper(&self.target_path).and_then(|bg| {
308                renderer
309                    .load_texture(&bg, size.width, size.height, 0)
310                    .ok()
311                    .map(|(tex, bind, width, height)| (tex, bind, (width, height)))
312            }) {
313                Some((tex, bind, size)) => (tex, bind, size),
314                None => {
315                    let black = image::DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel(
316                        w.max(1),
317                        h.max(1),
318                        image::Rgba([0, 0, 0, 255]),
319                    ));
320                    match renderer.load_texture(&black, size.width, size.height, 0) {
321                        Ok((tex, bind, width, height)) => (tex, bind, (width, height)),
322                        Err(e) => {
323                            eprintln!("failed to upload background: {e}");
324                            event_loop.exit();
325                            return;
326                        }
327                    }
328                }
329            };
330
331        // When the target is an animated GIF, the transition runs once over
332        // the first frame and then the preview switches to live playback,
333        // just like the daemon does on the desktop.
334        self.animated = crate::animated::AnimatedImage::decode(&self.target_path)
335            .ok()
336            .flatten();
337        if let Some(anim) = self.animated.as_mut() {
338            let (w, h) = (anim.width, anim.height);
339            let (tex, bind) = match renderer.create_texture(w, h) {
340                Ok(texture) => texture,
341                Err(e) => {
342                    eprintln!("failed to create GIF texture: {e}");
343                    event_loop.exit();
344                    return;
345                }
346            };
347            let first = anim.first_frame();
348            if !first.is_empty() {
349                renderer.update_texture(&tex, first, w, h);
350            }
351            self.play_tex = Some(tex);
352            self.play_bind = Some(bind);
353        }
354
355        self.window = Some(window);
356        self.per_output_uniforms = Some(renderer.create_per_output_uniforms());
357        self.renderer = Some(renderer);
358        self.surface = Some(surface);
359        self.surface_format = Some(format);
360        self.bg_bind = Some(bg_bind);
361        self.new_bind = Some(new_bind);
362        self._bg_tex = Some(bg_tex);
363        self._new_tex = Some(new_tex);
364        self.img_size = (w, h);
365        self.old_img_size = old_size;
366        self.start = Some(Instant::now());
367    }
368
369    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
370        match event {
371            WindowEvent::CloseRequested => event_loop.exit(),
372            WindowEvent::Resized(size) => {
373                if let (Some(renderer), Some(surface), Some(format)) =
374                    (&self.renderer, &self.surface, self.surface_format)
375                {
376                    self.configure_surface(renderer, surface, format, size.width, size.height);
377                }
378            }
379            WindowEvent::RedrawRequested => self.render_frame(event_loop),
380            _ => {}
381        }
382    }
383
384    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
385        if let Some(window) = &self.window {
386            window.request_redraw();
387        }
388    }
389}
390
391impl PreviewApp {
392    fn render_frame(&mut self, event_loop: &ActiveEventLoop) {
393        // Video targets skip the transition and stream frames live.
394        if self.video.is_some() {
395            self.render_video_frame(event_loop);
396            return;
397        }
398
399        let Some(start) = self.start else { return };
400
401        let elapsed = start.elapsed().as_millis() as f32;
402        let total = self.duration.as_millis() as f32;
403        let progress = if self.animated.is_some() {
404            // Animated targets: the transition runs exactly once, then the
405            // preview switches to live playback of the GIF frames.
406            (elapsed / total).min(1.0)
407        } else {
408            // Loop the transition forever so the effect can be judged
409            // repeatedly. The final frame is held for 25% of the duration so
410            // the loop restart reads as a deliberate pause, not a hard snap
411            // back to the outgoing wallpaper.
412            let hold = total * 0.25;
413            let cycle = elapsed % (total + hold);
414            (cycle / total).min(1.0)
415        };
416
417        let size = self
418            .window
419            .as_ref()
420            .map(|w| w.inner_size())
421            .unwrap_or(winit::dpi::PhysicalSize::new(1, 1));
422
423        if self.animated.is_some() && progress >= 1.0 {
424            self.render_live_frame(event_loop, size, elapsed, total);
425            return;
426        }
427
428        let Some(renderer) = &self.renderer else {
429            return;
430        };
431        let Some(surface) = &self.surface else { return };
432        let Some(format) = self.surface_format else {
433            return;
434        };
435        let Some(bg) = &self.bg_bind else { return };
436        let Some(new_bind) = &self.new_bind else {
437            return;
438        };
439
440        let (img_w, img_h) = self.img_size;
441        let (old_w, old_h) = self.old_img_size;
442
443        let uniforms = compute_effect_uniforms(&self.effect, progress);
444
445        match renderer.render_frame(
446            crate::renderer::FrameRequest {
447                surface,
448                format,
449                bg_bind: bg,
450                new_bind,
451                effect: &uniforms,
452                width: size.width,
453                height: size.height,
454                img_width: img_w,
455                img_height: img_h,
456                old_img_width: old_w,
457                old_img_height: old_h,
458                scaling_mode: 0,
459            },
460            self.per_output_uniforms.as_ref().unwrap(),
461        ) {
462            Ok(crate::renderer::FrameStatus::TimedOut) => {
463                // The surface is not presenting right now (e.g. the window is
464                // hidden or the monitor is off); keep polling so the preview
465                // resumes cleanly once frames flow again.
466            }
467            Err(e) => {
468                eprintln!("render error: {e}");
469                event_loop.exit();
470            }
471            _ => {}
472        }
473    }
474
475    /// Mirrors the daemon's `play_video` loop: pull the next displayable frame
476    /// per vsync and present it, never blocking on the bounded decoder queue.
477    fn render_video_frame(&mut self, event_loop: &ActiveEventLoop) {
478        let Some(renderer) = &self.renderer else {
479            return;
480        };
481        let Some(surface) = &self.surface else { return };
482        let Some(format) = self.surface_format else {
483            return;
484        };
485        let Some(playback) = &self.video else { return };
486        let Some(texture) = &self.video_texture else {
487            return;
488        };
489
490        let size = self
491            .window
492            .as_ref()
493            .map(|w| w.inner_size())
494            .unwrap_or(winit::dpi::PhysicalSize::new(1, 1));
495
496        let (w, h) = self.video_size;
497        if let Some(frame) = playback.next_frame()
498            && frame.width == w
499            && frame.height == h
500        {
501            if let Err(e) = renderer.update_video_texture(texture, &frame.data) {
502                eprintln!("video upload error: {e}");
503                event_loop.exit();
504                return;
505            }
506        }
507
508        let uniforms = compute_effect_uniforms(&self.effect, 1.0);
509        match renderer.render_frame(
510            crate::renderer::FrameRequest {
511                surface,
512                format,
513                bg_bind: texture.bind_group(),
514                new_bind: texture.bind_group(),
515                effect: &uniforms,
516                width: size.width.max(1),
517                height: size.height.max(1),
518                img_width: w,
519                img_height: h,
520                old_img_width: w,
521                old_img_height: h,
522                scaling_mode: 0,
523            },
524            self.per_output_uniforms.as_ref().unwrap(),
525        ) {
526            Ok(crate::renderer::FrameStatus::TimedOut) => {}
527            Err(e) => {
528                eprintln!("render error: {e}");
529                event_loop.exit();
530            }
531            _ => {}
532        }
533    }
534
535    /// Present GIF frames live, one per vsync, after the initial transition
536    /// has completed. Texture uploads only happen when the playhead crosses
537    /// into a new frame, so playback is smooth without re-uploading frames
538    /// that are already showing.
539    fn render_live_frame(
540        &mut self,
541        event_loop: &ActiveEventLoop,
542        size: winit::dpi::PhysicalSize<u32>,
543        elapsed: f32,
544        total: f32,
545    ) {
546        let Some(renderer) = &self.renderer else {
547            return;
548        };
549        let Some(surface) = &self.surface else { return };
550        let Some(format) = self.surface_format else {
551            return;
552        };
553        let Some(anim) = self.animated.as_mut() else {
554            return;
555        };
556        let Some(tex) = &self.play_tex else { return };
557        let Some(bind) = &self.play_bind else { return };
558
559        let (anim_w, anim_h) = (anim.width, anim.height);
560        let live_ms = (elapsed - total).max(0.0) as u64;
561        let index = anim.frame_index_at(std::time::Duration::from_millis(live_ms));
562        if index != self.shown_frame {
563            let frame = anim.frame_at(index);
564            if !frame.is_empty() {
565                renderer.update_texture(tex, frame, anim_w, anim_h);
566                self.shown_frame = index;
567            }
568        }
569
570        let uniforms = compute_effect_uniforms(&self.effect, 1.0);
571        match renderer.render_frame(
572            crate::renderer::FrameRequest {
573                surface,
574                format,
575                bg_bind: bind,
576                new_bind: bind,
577                effect: &uniforms,
578                width: size.width,
579                height: size.height,
580                img_width: anim.width,
581                img_height: anim.height,
582                old_img_width: anim.width,
583                old_img_height: anim.height,
584                scaling_mode: 0,
585            },
586            self.per_output_uniforms.as_ref().unwrap(),
587        ) {
588            Ok(crate::renderer::FrameStatus::TimedOut) => {}
589            Err(e) => {
590                eprintln!("render error: {e}");
591                event_loop.exit();
592            }
593            _ => {}
594        }
595    }
596}