Skip to main content

preview/
lib.rs

1//! `preview` — native winit + wgpu sibling window that wisp renders into.
2//!
3//! The `[[bin]]` target (`src/main.rs`) opens a real OS window via winit,
4//! hands its wgpu surface to [`wisp::application::Application::from_wgpu`],
5//! and pumps the [`playback::Player`] into it. The library half exposes the
6//! pure helpers (math, headless render harness) so `[[example]]` and tests
7//! can exercise the same render path without a window.
8
9use glam::Vec2;
10
11/// Letterbox/pillarbox scale that fits a `video_w × video_h` source into a
12/// `surface_w × surface_h` viewport, expressed as a [`Vec2`] for the wisp
13/// sprite's transform `scale`.
14///
15/// The fully-covered axis returns `2.0` (the full NDC `[-1, +1]` range applied
16/// to a default unit-quad sprite); the bound axis returns the proportional
17/// fraction of `2.0`.
18///
19/// Returns `Vec2::splat(1.0)` when any dimension is zero — guard for edge
20/// cases (window minimized, decoder reports a malformed stream) without
21/// risking `NaN` in the transform.
22#[must_use]
23pub fn aspect_fit_scale(surface_w: u32, surface_h: u32, video_w: u32, video_h: u32) -> Vec2 {
24    if surface_w == 0 || surface_h == 0 || video_w == 0 || video_h == 0 {
25        return Vec2::splat(1.0);
26    }
27    #[allow(
28        clippy::cast_precision_loss,
29        reason = "surface and video dimensions fit comfortably in f32"
30    )]
31    let surface_aspect = surface_w as f32 / surface_h as f32;
32    #[allow(
33        clippy::cast_precision_loss,
34        reason = "surface and video dimensions fit comfortably in f32"
35    )]
36    let video_aspect = video_w as f32 / video_h as f32;
37    if video_aspect > surface_aspect {
38        Vec2::new(2.0, 2.0 * surface_aspect / video_aspect)
39    } else {
40        Vec2::new(2.0 * video_aspect / surface_aspect, 2.0)
41    }
42}
43
44#[cfg(test)]
45mod tests {
46    use super::aspect_fit_scale;
47    use glam::Vec2;
48
49    /// Equality with a tolerance — `f32` aspect-ratio math has small rounding noise.
50    fn approx(a: Vec2, b: Vec2) {
51        assert!(
52            (a.x - b.x).abs() < 1e-4 && (a.y - b.y).abs() < 1e-4,
53            "{a:?} != {b:?}"
54        );
55    }
56
57    #[test]
58    fn matching_aspect_fills_full_ndc() {
59        approx(aspect_fit_scale(1920, 1080, 1920, 1080), Vec2::splat(2.0));
60        approx(aspect_fit_scale(1000, 1000, 512, 512), Vec2::splat(2.0));
61    }
62
63    #[test]
64    fn wider_video_letterboxes_top_and_bottom() {
65        // 16:9 video into a square surface — full width, shorter height.
66        let scale = aspect_fit_scale(1000, 1000, 1920, 1080);
67        approx(scale, Vec2::new(2.0, 2.0 * (1.0_f32 / (16.0 / 9.0))));
68        assert!(scale.y < scale.x, "letterbox: y < x");
69    }
70
71    #[test]
72    fn taller_video_pillarboxes_left_and_right() {
73        // Portrait 9:16 video into a square surface — full height, narrower width.
74        let scale = aspect_fit_scale(1000, 1000, 1080, 1920);
75        approx(scale, Vec2::new(2.0 * (9.0 / 16.0), 2.0));
76        assert!(scale.x < scale.y, "pillarbox: x < y");
77    }
78
79    #[test]
80    fn zero_dimensions_return_identity() {
81        approx(aspect_fit_scale(0, 1080, 1920, 1080), Vec2::splat(1.0));
82        approx(aspect_fit_scale(1920, 0, 1920, 1080), Vec2::splat(1.0));
83        approx(aspect_fit_scale(1920, 1080, 0, 1080), Vec2::splat(1.0));
84        approx(aspect_fit_scale(1920, 1080, 1920, 0), Vec2::splat(1.0));
85    }
86}