Skip to main content

oximedia_transcode/
resolution_select.rs

1//! Resolution tier selection for adaptive transcoding.
2//!
3//! Maps bandwidth budgets and quality targets to standard resolution tiers,
4//! supporting adaptive bitrate ladder construction and device capability matching.
5
6#![allow(dead_code)]
7
8use serde::{Deserialize, Serialize};
9
10/// Standard resolution tiers used in adaptive streaming and archiving.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12pub enum ResolutionTier {
13    /// 480p SD (854×480).
14    Sd,
15    /// 720p HD (1280×720).
16    Hd,
17    /// 1080p Full HD (1920×1080).
18    FullHd,
19    /// 2160p Ultra HD 4K (3840×2160).
20    Uhd4k,
21}
22
23impl ResolutionTier {
24    /// Total pixel count for this tier.
25    #[must_use]
26    pub fn pixel_count(self) -> u64 {
27        let (w, h) = self.dimensions();
28        u64::from(w) * u64::from(h)
29    }
30
31    /// Canonical width and height (pixels) for this tier.
32    #[must_use]
33    pub fn dimensions(self) -> (u32, u32) {
34        match self {
35            Self::Sd => (854, 480),
36            Self::Hd => (1280, 720),
37            Self::FullHd => (1920, 1080),
38            Self::Uhd4k => (3840, 2160),
39        }
40    }
41
42    /// Commonly recommended video bitrate (bits per second) for H.264/AVC.
43    #[must_use]
44    pub fn recommended_bitrate_bps(self) -> u64 {
45        match self {
46            Self::Sd => 1_500_000,
47            Self::Hd => 4_000_000,
48            Self::FullHd => 8_000_000,
49            Self::Uhd4k => 40_000_000,
50        }
51    }
52
53    /// Human-readable label.
54    #[must_use]
55    pub fn label(self) -> &'static str {
56        match self {
57            Self::Sd => "480p",
58            Self::Hd => "720p",
59            Self::FullHd => "1080p",
60            Self::Uhd4k => "2160p",
61        }
62    }
63
64    /// Returns an ordered list of all tiers from lowest to highest.
65    #[must_use]
66    pub fn all_tiers() -> [Self; 4] {
67        [Self::Sd, Self::Hd, Self::FullHd, Self::Uhd4k]
68    }
69}
70
71/// Strategy to use when selecting among resolution tiers.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub enum SelectionStrategy {
74    /// Choose the highest tier that fits within the bandwidth budget.
75    BandwidthFit,
76    /// Choose the highest tier regardless of bandwidth.
77    MaxQuality,
78    /// Choose the lowest tier (minimise bandwidth).
79    MinBandwidth,
80    /// Choose an exact tier.
81    Exact(ResolutionTier),
82}
83
84/// Selects a resolution tier based on bandwidth or quality constraints.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ResolutionSelector {
87    /// Maximum available bandwidth in bits per second.
88    pub max_bandwidth_bps: u64,
89    /// Minimum acceptable tier.
90    pub min_tier: ResolutionTier,
91    /// Maximum acceptable tier.
92    pub max_tier: ResolutionTier,
93    /// Headroom factor applied to the recommended bitrate when fitting.
94    /// A value of 1.2 means 20 % safety margin is required.
95    pub headroom_factor: f64,
96}
97
98impl Default for ResolutionSelector {
99    fn default() -> Self {
100        Self {
101            max_bandwidth_bps: 10_000_000,
102            min_tier: ResolutionTier::Sd,
103            max_tier: ResolutionTier::Uhd4k,
104            headroom_factor: 1.2,
105        }
106    }
107}
108
109impl ResolutionSelector {
110    /// Create a selector with a specific bandwidth ceiling.
111    #[must_use]
112    pub fn new(max_bandwidth_bps: u64) -> Self {
113        Self {
114            max_bandwidth_bps,
115            ..Self::default()
116        }
117    }
118
119    /// Set the minimum tier floor.
120    #[must_use]
121    pub fn with_min_tier(mut self, tier: ResolutionTier) -> Self {
122        self.min_tier = tier;
123        self
124    }
125
126    /// Set the maximum tier ceiling.
127    #[must_use]
128    pub fn with_max_tier(mut self, tier: ResolutionTier) -> Self {
129        self.max_tier = tier;
130        self
131    }
132
133    /// Set the headroom factor (must be >= 1.0).
134    #[must_use]
135    pub fn with_headroom(mut self, factor: f64) -> Self {
136        self.headroom_factor = factor.max(1.0);
137        self
138    }
139
140    /// Select the highest tier that fits within the configured bandwidth budget.
141    ///
142    /// Returns `None` if no tier (not even the minimum) fits.
143    #[allow(clippy::cast_precision_loss)]
144    #[must_use]
145    pub fn select_for_bandwidth(&self) -> Option<ResolutionTier> {
146        let budget = self.max_bandwidth_bps as f64 / self.headroom_factor;
147        let mut best: Option<ResolutionTier> = None;
148        for tier in ResolutionTier::all_tiers() {
149            if tier < self.min_tier || tier > self.max_tier {
150                continue;
151            }
152            if tier.recommended_bitrate_bps() as f64 <= budget {
153                best = Some(tier);
154            }
155        }
156        best
157    }
158
159    /// Select the maximum tier within the configured min/max bounds (ignores bandwidth).
160    #[must_use]
161    pub fn select_for_quality(&self) -> ResolutionTier {
162        ResolutionTier::all_tiers()
163            .iter()
164            .filter(|&&t| t >= self.min_tier && t <= self.max_tier)
165            .copied()
166            .last()
167            .unwrap_or(self.min_tier)
168    }
169
170    /// Return all tiers that fall within the configured min/max bounds.
171    #[must_use]
172    pub fn all_tiers(&self) -> Vec<ResolutionTier> {
173        ResolutionTier::all_tiers()
174            .iter()
175            .filter(|&&t| t >= self.min_tier && t <= self.max_tier)
176            .copied()
177            .collect()
178    }
179
180    /// Build an ABR ladder: return all tiers that fit within the bandwidth budget.
181    #[allow(clippy::cast_precision_loss)]
182    #[must_use]
183    pub fn abr_ladder(&self) -> Vec<ResolutionTier> {
184        let budget = self.max_bandwidth_bps as f64 / self.headroom_factor;
185        ResolutionTier::all_tiers()
186            .iter()
187            .filter(|&&t| {
188                t >= self.min_tier
189                    && t <= self.max_tier
190                    && t.recommended_bitrate_bps() as f64 <= budget
191            })
192            .copied()
193            .collect()
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_pixel_count_ordering() {
203        assert!(ResolutionTier::Sd.pixel_count() < ResolutionTier::Hd.pixel_count());
204        assert!(ResolutionTier::Hd.pixel_count() < ResolutionTier::FullHd.pixel_count());
205        assert!(ResolutionTier::FullHd.pixel_count() < ResolutionTier::Uhd4k.pixel_count());
206    }
207
208    #[test]
209    fn test_sd_dimensions() {
210        assert_eq!(ResolutionTier::Sd.dimensions(), (854, 480));
211    }
212
213    #[test]
214    fn test_uhd4k_dimensions() {
215        assert_eq!(ResolutionTier::Uhd4k.dimensions(), (3840, 2160));
216    }
217
218    #[test]
219    fn test_recommended_bitrate_increases_with_tier() {
220        let tiers = ResolutionTier::all_tiers();
221        for window in tiers.windows(2) {
222            assert!(window[0].recommended_bitrate_bps() < window[1].recommended_bitrate_bps());
223        }
224    }
225
226    #[test]
227    fn test_labels_non_empty() {
228        for t in ResolutionTier::all_tiers() {
229            assert!(!t.label().is_empty());
230        }
231    }
232
233    #[test]
234    fn test_all_tiers_count() {
235        assert_eq!(ResolutionTier::all_tiers().len(), 4);
236    }
237
238    #[test]
239    fn test_select_for_bandwidth_high_budget() {
240        let sel = ResolutionSelector::new(100_000_000);
241        assert_eq!(sel.select_for_bandwidth(), Some(ResolutionTier::Uhd4k));
242    }
243
244    #[test]
245    fn test_select_for_bandwidth_low_budget() {
246        // 1 Mbps is below even SD recommended bitrate with 1.2x headroom
247        let sel = ResolutionSelector::new(1_000_000).with_headroom(1.0);
248        assert!(sel.select_for_bandwidth().is_none());
249    }
250
251    #[test]
252    fn test_select_for_bandwidth_mid_budget() {
253        // 5 Mbps should fit HD but not FullHD (8 Mbps)
254        let sel = ResolutionSelector::new(5_000_000).with_headroom(1.0);
255        assert_eq!(sel.select_for_bandwidth(), Some(ResolutionTier::Hd));
256    }
257
258    #[test]
259    fn test_select_for_quality_returns_max_tier() {
260        let sel = ResolutionSelector::default().with_max_tier(ResolutionTier::FullHd);
261        assert_eq!(sel.select_for_quality(), ResolutionTier::FullHd);
262    }
263
264    #[test]
265    fn test_all_tiers_bounded() {
266        let sel = ResolutionSelector::default()
267            .with_min_tier(ResolutionTier::Hd)
268            .with_max_tier(ResolutionTier::FullHd);
269        let tiers = sel.all_tiers();
270        assert_eq!(tiers, vec![ResolutionTier::Hd, ResolutionTier::FullHd]);
271    }
272
273    #[test]
274    fn test_abr_ladder_large_budget() {
275        let sel = ResolutionSelector::new(100_000_000).with_headroom(1.0);
276        assert_eq!(sel.abr_ladder().len(), 4);
277    }
278
279    #[test]
280    fn test_abr_ladder_small_budget() {
281        // Budget just covers HD (4 Mbps) but not FullHD (8 Mbps)
282        let sel = ResolutionSelector::new(4_000_000).with_headroom(1.0);
283        let ladder = sel.abr_ladder();
284        assert!(ladder.contains(&ResolutionTier::Hd));
285        assert!(!ladder.contains(&ResolutionTier::FullHd));
286    }
287
288    #[test]
289    fn test_tier_ordering() {
290        assert!(ResolutionTier::Sd < ResolutionTier::Hd);
291        assert!(ResolutionTier::Hd < ResolutionTier::FullHd);
292        assert!(ResolutionTier::FullHd < ResolutionTier::Uhd4k);
293    }
294}