Skip to main content

rskit_media/chunking/strategy/
keyframe.rs

1//! Keyframe-aligned chunk strategy.
2
3use std::time::Duration;
4
5use rskit_errors::AppResult;
6
7use crate::chunking::types::{ChunkBoundary, ChunkId, ChunkPlan, ChunkedOperation, ReassemblyPlan};
8use crate::probe::MediaMetadata;
9use crate::time::{TimeRange, Timestamp};
10
11use super::chunk_strategy::ChunkStrategy;
12use super::fixed_duration::FixedDurationStrategy;
13
14/// Split media at keyframe boundaries (for lossless video splitting).
15///
16/// Attempts to place chunk boundaries on keyframes to avoid re-encoding at split points.
17pub struct KeyframeStrategy {
18    /// Target chunk count (will adjust based on available keyframes).
19    pub target_chunks: usize,
20    /// Minimum chunk duration (avoids tiny chunks).
21    pub min_chunk_duration: Duration,
22    /// Timeout multiplier per chunk.
23    pub timeout_multiplier: f64,
24}
25
26impl Default for KeyframeStrategy {
27    fn default() -> Self {
28        Self {
29            target_chunks: 4,
30            min_chunk_duration: Duration::from_secs(60),
31            timeout_multiplier: 3.0,
32        }
33    }
34}
35
36impl KeyframeStrategy {
37    /// Set the target number of chunks.
38    #[must_use]
39    pub fn with_target_chunks(mut self, count: usize) -> Self {
40        self.target_chunks = count.max(1);
41        self
42    }
43}
44
45impl ChunkStrategy for KeyframeStrategy {
46    fn name(&self) -> &str {
47        "keyframe"
48    }
49
50    fn plan(
51        &self,
52        metadata: &MediaMetadata,
53        boundaries: &[ChunkBoundary],
54    ) -> AppResult<ChunkedOperation> {
55        let total_duration = metadata.duration.unwrap_or_default();
56        if total_duration.is_zero() {
57            return Err(rskit_errors::AppError::new(
58                rskit_errors::ErrorCode::InvalidInput,
59                "cannot chunk media with zero duration",
60            ));
61        }
62        if self.target_chunks == 0 {
63            return Err(rskit_errors::AppError::new(
64                rskit_errors::ErrorCode::InvalidInput,
65                "target_chunks must be greater than zero",
66            ));
67        }
68
69        // Filter to keyframe-only boundaries
70        let keyframes: Vec<&ChunkBoundary> = boundaries.iter().filter(|b| b.is_keyframe).collect();
71
72        // If too few keyframes, fall back to fixed duration
73        if keyframes.len() < self.target_chunks {
74            let fallback = FixedDurationStrategy {
75                chunk_duration: Duration::from_secs_f64(
76                    total_duration.as_secs_f64() / self.target_chunks as f64,
77                ),
78                timeout_multiplier: self.timeout_multiplier,
79                ..Default::default()
80            };
81            return fallback.plan(metadata, boundaries);
82        }
83
84        // Select keyframes that divide the media into roughly equal parts
85        let ideal_chunk_duration = total_duration.as_secs_f64() / self.target_chunks as f64;
86        let min_chunk_us = self.min_chunk_duration.as_micros() as u64;
87        let total_us = total_duration.as_micros() as u64;
88
89        let mut split_points: Vec<Timestamp> = Vec::new();
90        let mut next_ideal_us = (ideal_chunk_duration * 1_000_000.0) as u64;
91
92        for kf in &keyframes {
93            if kf.timestamp.as_micros() >= total_us {
94                break;
95            }
96            if kf.timestamp.as_micros() >= next_ideal_us
97                && kf.timestamp.as_micros() >= min_chunk_us
98                && (total_us - kf.timestamp.as_micros()) >= min_chunk_us
99            {
100                split_points.push(kf.timestamp);
101                next_ideal_us =
102                    kf.timestamp.as_micros() + (ideal_chunk_duration * 1_000_000.0) as u64;
103            }
104        }
105
106        // Build chunk plans from split points
107        let mut chunks = Vec::new();
108        let mut current_start = Timestamp(0);
109
110        for (index, &split) in split_points.iter().enumerate() {
111            let range = TimeRange::new(current_start, split);
112            let chunk_dur = range.duration();
113            chunks.push(ChunkPlan {
114                id: ChunkId::from_index(index),
115                index,
116                range,
117                start_is_keyframe: true,
118                suggested_timeout: Duration::from_secs_f64(
119                    chunk_dur.as_secs_f64() * self.timeout_multiplier,
120                ),
121            });
122            current_start = split;
123        }
124
125        // Final chunk
126        let final_range = TimeRange::new(current_start, Timestamp(total_us));
127        let final_dur = final_range.duration();
128        chunks.push(ChunkPlan {
129            id: ChunkId::from_index(split_points.len()),
130            index: split_points.len(),
131            range: final_range,
132            start_is_keyframe: true,
133            suggested_timeout: Duration::from_secs_f64(
134                final_dur.as_secs_f64() * self.timeout_multiplier,
135            ),
136        });
137
138        Ok(ChunkedOperation {
139            chunks,
140            reassembly: ReassemblyPlan::Concat,
141            total_duration,
142            strategy_name: self.name().to_string(),
143        })
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::super::test_support::{make_keyframe_boundaries, make_metadata};
150    use super::*;
151
152    #[test]
153    fn keyframe_strategy_creates_target_chunks() {
154        let strategy = KeyframeStrategy {
155            target_chunks: 4,
156            ..Default::default()
157        };
158        let metadata = make_metadata(3600.0);
159        let boundaries = make_keyframe_boundaries(3600.0, 2.0);
160        let result = strategy.plan(&metadata, &boundaries).unwrap();
161        // Should create roughly 4-5 chunks (exact count depends on keyframe alignment)
162        assert!(result.chunk_count() >= 3);
163        assert!(result.chunk_count() <= 6);
164    }
165
166    #[test]
167    fn keyframe_strategy_handles_zero_duration_and_fallback() {
168        let zero = make_metadata(0.0);
169        assert!(KeyframeStrategy::default().plan(&zero, &[]).is_err());
170
171        let strategy = KeyframeStrategy::default().with_target_chunks(0);
172        assert_eq!(strategy.target_chunks, 1);
173
174        let metadata = make_metadata(1200.0);
175        let sparse = vec![ChunkBoundary {
176            timestamp: Timestamp::from_seconds(60.0),
177            is_keyframe: true,
178            quality: 1.0,
179        }];
180        let plan = KeyframeStrategy {
181            target_chunks: 4,
182            ..Default::default()
183        }
184        .plan(&metadata, &sparse)
185        .unwrap();
186        assert_eq!(plan.strategy_name, "fixed_duration");
187    }
188
189    #[test]
190    fn keyframe_strategy_rejects_zero_target_chunks() {
191        let strategy = KeyframeStrategy {
192            target_chunks: 0,
193            ..Default::default()
194        };
195        let err = strategy.plan(&make_metadata(1200.0), &[]).unwrap_err();
196        assert_eq!(err.code(), rskit_errors::ErrorCode::InvalidInput);
197    }
198}