Skip to main content

rskit_media/chunking/strategy/
fixed_duration.rs

1//! Fixed-duration 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::boundary::snap_to_boundary;
12use super::chunk_strategy::ChunkStrategy;
13
14/// Split media at fixed duration intervals.
15///
16/// Simple strategy that creates equal-sized chunks.
17/// Chunk boundaries are snapped to the nearest provided boundary point (keyframe/silence).
18pub struct FixedDurationStrategy {
19    /// Target duration per chunk.
20    pub chunk_duration: Duration,
21    /// Maximum allowed deviation from target when snapping to boundaries.
22    pub snap_tolerance: Duration,
23    /// How to reassemble chunks.
24    pub reassembly: ReassemblyPlan,
25    /// Timeout multiplier per chunk (based on chunk duration).
26    pub timeout_multiplier: f64,
27}
28
29impl Default for FixedDurationStrategy {
30    fn default() -> Self {
31        Self {
32            chunk_duration: Duration::from_secs(600), // 10 minutes
33            snap_tolerance: Duration::from_secs(5),
34            reassembly: ReassemblyPlan::Concat,
35            timeout_multiplier: 3.0,
36        }
37    }
38}
39
40impl FixedDurationStrategy {
41    /// Create a strategy with the given chunk duration.
42    #[must_use]
43    pub fn with_chunk_duration(mut self, duration: Duration) -> Self {
44        self.chunk_duration = duration;
45        self
46    }
47
48    /// Set the snap tolerance.
49    #[must_use]
50    pub fn with_snap_tolerance(mut self, tolerance: Duration) -> Self {
51        self.snap_tolerance = tolerance;
52        self
53    }
54
55    /// Set the reassembly plan.
56    #[must_use]
57    pub fn with_reassembly(mut self, reassembly: ReassemblyPlan) -> Self {
58        self.reassembly = reassembly;
59        self
60    }
61
62    /// Set the timeout multiplier.
63    #[must_use]
64    pub fn with_timeout_multiplier(mut self, multiplier: f64) -> Self {
65        self.timeout_multiplier = multiplier;
66        self
67    }
68}
69
70impl ChunkStrategy for FixedDurationStrategy {
71    fn name(&self) -> &str {
72        "fixed_duration"
73    }
74
75    fn plan(
76        &self,
77        metadata: &MediaMetadata,
78        boundaries: &[ChunkBoundary],
79    ) -> AppResult<ChunkedOperation> {
80        let total_duration = metadata.duration.unwrap_or_default();
81        if total_duration.is_zero() {
82            return Err(rskit_errors::AppError::new(
83                rskit_errors::ErrorCode::InvalidInput,
84                "cannot chunk media with zero duration",
85            ));
86        }
87        if self.chunk_duration.is_zero() {
88            return Err(rskit_errors::AppError::new(
89                rskit_errors::ErrorCode::InvalidInput,
90                "chunk_duration must be greater than zero",
91            ));
92        }
93
94        // If duration is less than 1.5x chunk size, don't bother splitting
95        let threshold = self.chunk_duration.mul_f64(1.5);
96        if total_duration <= threshold {
97            let plan = ChunkPlan {
98                id: ChunkId::from_index(0),
99                index: 0,
100                range: TimeRange::new(Timestamp(0), Timestamp(total_duration.as_micros() as u64)),
101                start_is_keyframe: true,
102                suggested_timeout: Duration::from_secs_f64(
103                    total_duration.as_secs_f64() * self.timeout_multiplier,
104                ),
105            };
106            return Ok(ChunkedOperation {
107                chunks: vec![plan],
108                reassembly: self.reassembly.clone(),
109                total_duration,
110                strategy_name: self.name().to_string(),
111            });
112        }
113
114        let chunk_us = self.chunk_duration.as_micros() as u64;
115        let total_us = total_duration.as_micros() as u64;
116        let snap_us = self.snap_tolerance.as_micros() as u64;
117
118        let mut chunks = Vec::new();
119        let mut current_start = Timestamp(0);
120        let mut index = 0;
121
122        while current_start.as_micros() < total_us {
123            let ideal_end_us = current_start
124                .as_micros()
125                .saturating_add(chunk_us)
126                .min(total_us);
127            let ideal_end = Timestamp(ideal_end_us);
128
129            // If this is the last chunk (or close to the end), extend to the end
130            let remaining = total_us.saturating_sub(ideal_end_us);
131            let end = if remaining < chunk_us / 2 {
132                Timestamp(total_us)
133            } else {
134                snap_to_boundary(ideal_end, boundaries, snap_us)
135            };
136
137            let is_keyframe = if index == 0 {
138                true
139            } else {
140                boundaries
141                    .iter()
142                    .any(|b| b.is_keyframe && b.timestamp == current_start)
143            };
144
145            let range = TimeRange::new(current_start, end);
146            let chunk_dur = range.duration();
147
148            chunks.push(ChunkPlan {
149                id: ChunkId::from_index(index),
150                index,
151                range,
152                start_is_keyframe: is_keyframe,
153                suggested_timeout: Duration::from_secs_f64(
154                    chunk_dur.as_secs_f64() * self.timeout_multiplier,
155                ),
156            });
157
158            current_start = end;
159            index += 1;
160        }
161
162        Ok(ChunkedOperation {
163            chunks,
164            reassembly: self.reassembly.clone(),
165            total_duration,
166            strategy_name: self.name().to_string(),
167        })
168    }
169
170    fn min_duration(&self) -> Option<Duration> {
171        Some(self.chunk_duration.mul_f64(1.5))
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::super::test_support::{make_keyframe_boundaries, make_metadata};
178    use super::*;
179
180    #[test]
181    fn fixed_duration_single_chunk_for_short_media() {
182        let strategy = FixedDurationStrategy::default(); // 10 min chunks
183        let metadata = make_metadata(300.0); // 5 minutes
184        let result = strategy.plan(&metadata, &[]).unwrap();
185        assert_eq!(result.chunk_count(), 1);
186        assert!(result.is_single_chunk());
187    }
188
189    #[test]
190    fn fixed_duration_multiple_chunks_for_long_media() {
191        let strategy = FixedDurationStrategy::default(); // 10 min chunks
192        let metadata = make_metadata(3600.0); // 60 minutes
193        let boundaries = make_keyframe_boundaries(3600.0, 2.0);
194        let result = strategy.plan(&metadata, &boundaries).unwrap();
195        assert!(result.chunk_count() >= 5);
196        assert!(result.chunk_count() <= 7);
197
198        // Verify chunks cover the full duration
199        let first = &result.chunks[0];
200        let last = result.chunks.last().unwrap();
201        assert_eq!(first.range.start.as_micros(), 0);
202        assert_eq!(last.range.end.as_millis(), 3_600_000);
203    }
204
205    #[test]
206    fn fixed_duration_rejects_zero_duration() {
207        let strategy = FixedDurationStrategy::default();
208        let metadata = make_metadata(0.0);
209        assert!(strategy.plan(&metadata, &[]).is_err());
210    }
211
212    #[test]
213    fn fixed_duration_rejects_zero_chunk_duration() {
214        let strategy = FixedDurationStrategy::default().with_chunk_duration(Duration::ZERO);
215        let err = strategy.plan(&make_metadata(300.0), &[]).unwrap_err();
216        assert_eq!(err.code(), rskit_errors::ErrorCode::InvalidInput);
217    }
218
219    #[test]
220    fn fixed_duration_builders_and_min_duration_are_reported() {
221        let strategy = FixedDurationStrategy::default()
222            .with_chunk_duration(Duration::from_secs(120))
223            .with_snap_tolerance(Duration::from_secs(3))
224            .with_reassembly(ReassemblyPlan::MergeText {
225                separator: "\n".to_string(),
226            })
227            .with_timeout_multiplier(2.0);
228
229        assert_eq!(strategy.min_duration(), Some(Duration::from_secs(180)));
230        assert_eq!(strategy.snap_tolerance, Duration::from_secs(3));
231        assert_eq!(strategy.timeout_multiplier, 2.0);
232    }
233}