rskit_media/chunking/strategy/
keyframe.rs1use 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
14pub struct KeyframeStrategy {
18 pub target_chunks: usize,
20 pub min_chunk_duration: Duration,
22 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 #[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 let keyframes: Vec<&ChunkBoundary> = boundaries.iter().filter(|b| b.is_keyframe).collect();
71
72 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 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 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 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 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}