Skip to main content

oximedia_distributed/
segment_merge.rs

1#![allow(dead_code)]
2//! Distributed merge/concatenation of encoded segments after parallel encoding.
3//!
4//! After a video is split into segments and encoded in parallel across multiple
5//! workers, the segments must be reassembled into the final output file. This
6//! module provides:
7//!
8//! - [`SegmentManifest`] to track all segments belonging to a job.
9//! - [`MergeStrategy`] for different concatenation approaches.
10//! - [`SegmentMerger`] that validates, orders, and merges segments.
11//! - Gap/overlap detection and configurable error handling.
12
13use std::collections::HashMap;
14use std::fmt;
15use std::time::Instant;
16
17use uuid::Uuid;
18
19use crate::{DistributedError, Result};
20
21// ---------------------------------------------------------------------------
22// SegmentInfo
23// ---------------------------------------------------------------------------
24
25/// Metadata about a single encoded segment.
26#[derive(Debug, Clone)]
27pub struct SegmentInfo {
28    /// Unique segment ID.
29    pub id: Uuid,
30    /// Job this segment belongs to.
31    pub job_id: Uuid,
32    /// Zero-based segment index within the job.
33    pub index: u32,
34    /// Total number of segments expected for this job.
35    pub total_segments: u32,
36    /// Start time of this segment in the source timeline (microseconds).
37    pub start_time_us: i64,
38    /// End time of this segment in the source timeline (microseconds).
39    pub end_time_us: i64,
40    /// Size of the encoded segment data in bytes.
41    pub byte_size: u64,
42    /// Worker that produced this segment.
43    pub worker_id: Option<String>,
44    /// Storage location (e.g., an object store key).
45    pub storage_path: String,
46    /// Whether the segment has been validated (checksum OK, decodable, etc.).
47    pub validated: bool,
48}
49
50impl SegmentInfo {
51    /// Duration of this segment in microseconds.
52    pub fn duration_us(&self) -> i64 {
53        self.end_time_us - self.start_time_us
54    }
55}
56
57// ---------------------------------------------------------------------------
58// MergeStrategy
59// ---------------------------------------------------------------------------
60
61/// Strategy for how segments are merged.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum MergeStrategy {
64    /// Simple byte-level concatenation (suitable for transport streams).
65    ByteConcat,
66    /// Mux-level concatenation that rewrites container headers/indices.
67    ContainerRemux,
68    /// Use segment map (sidx) to build a fragmented output.
69    FragmentedMp4,
70}
71
72impl Default for MergeStrategy {
73    fn default() -> Self {
74        Self::ContainerRemux
75    }
76}
77
78impl fmt::Display for MergeStrategy {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::ByteConcat => write!(f, "ByteConcat"),
82            Self::ContainerRemux => write!(f, "ContainerRemux"),
83            Self::FragmentedMp4 => write!(f, "FragmentedMp4"),
84        }
85    }
86}
87
88// ---------------------------------------------------------------------------
89// GapPolicy
90// ---------------------------------------------------------------------------
91
92/// How to handle gaps or overlaps between adjacent segments.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum GapPolicy {
95    /// Reject the merge if any gap or overlap is detected.
96    Strict,
97    /// Allow gaps up to the specified tolerance (microseconds).
98    AllowGaps { tolerance_us: i64 },
99    /// Fill detected gaps with silence/black frames.
100    FillGaps,
101    /// Ignore gaps and overlaps entirely.
102    Ignore,
103}
104
105impl Default for GapPolicy {
106    fn default() -> Self {
107        Self::AllowGaps {
108            tolerance_us: 1_000, // 1ms
109        }
110    }
111}
112
113// ---------------------------------------------------------------------------
114// MergeConfig
115// ---------------------------------------------------------------------------
116
117/// Configuration for the segment merger.
118#[derive(Debug, Clone)]
119pub struct MergeConfig {
120    /// How to concatenate segments.
121    pub strategy: MergeStrategy,
122    /// How to handle gaps/overlaps.
123    pub gap_policy: GapPolicy,
124    /// Whether all segments must be validated before merge.
125    pub require_validation: bool,
126    /// Output storage path.
127    pub output_path: String,
128}
129
130impl Default for MergeConfig {
131    fn default() -> Self {
132        Self {
133            strategy: MergeStrategy::default(),
134            gap_policy: GapPolicy::default(),
135            require_validation: true,
136            output_path: String::new(),
137        }
138    }
139}
140
141// ---------------------------------------------------------------------------
142// SegmentManifest
143// ---------------------------------------------------------------------------
144
145/// Tracks all segments for a given job and their readiness for merging.
146#[derive(Debug, Clone)]
147pub struct SegmentManifest {
148    /// Job ID this manifest belongs to.
149    pub job_id: Uuid,
150    /// Expected total number of segments.
151    pub expected_count: u32,
152    /// Registered segments keyed by index.
153    segments: HashMap<u32, SegmentInfo>,
154}
155
156impl SegmentManifest {
157    /// Create a new manifest for the given job.
158    pub fn new(job_id: Uuid, expected_count: u32) -> Self {
159        Self {
160            job_id,
161            expected_count,
162            segments: HashMap::new(),
163        }
164    }
165
166    /// Register a completed segment.
167    pub fn register_segment(&mut self, segment: SegmentInfo) -> Result<()> {
168        if segment.job_id != self.job_id {
169            return Err(DistributedError::Job(format!(
170                "Segment job_id {} does not match manifest job_id {}",
171                segment.job_id, self.job_id
172            )));
173        }
174        if segment.index >= self.expected_count {
175            return Err(DistributedError::Segmentation(format!(
176                "Segment index {} exceeds expected count {}",
177                segment.index, self.expected_count
178            )));
179        }
180        if self.segments.contains_key(&segment.index) {
181            return Err(DistributedError::Segmentation(format!(
182                "Segment index {} already registered",
183                segment.index
184            )));
185        }
186        self.segments.insert(segment.index, segment);
187        Ok(())
188    }
189
190    /// Check whether all expected segments have been registered.
191    pub fn is_complete(&self) -> bool {
192        self.segments.len() as u32 == self.expected_count
193    }
194
195    /// Return the number of registered segments.
196    pub fn registered_count(&self) -> u32 {
197        self.segments.len() as u32
198    }
199
200    /// Return missing segment indices.
201    pub fn missing_indices(&self) -> Vec<u32> {
202        (0..self.expected_count)
203            .filter(|i| !self.segments.contains_key(i))
204            .collect()
205    }
206
207    /// Get segments ordered by index.
208    pub fn ordered_segments(&self) -> Vec<&SegmentInfo> {
209        let mut indices: Vec<u32> = self.segments.keys().copied().collect();
210        indices.sort();
211        indices
212            .iter()
213            .filter_map(|i| self.segments.get(i))
214            .collect()
215    }
216
217    /// Total byte size of all registered segments.
218    pub fn total_byte_size(&self) -> u64 {
219        self.segments.values().map(|s| s.byte_size).sum()
220    }
221}
222
223// ---------------------------------------------------------------------------
224// GapInfo
225// ---------------------------------------------------------------------------
226
227/// Describes a gap or overlap between two adjacent segments.
228#[derive(Debug, Clone)]
229pub struct GapInfo {
230    /// Index of the first segment.
231    pub before_index: u32,
232    /// Index of the second segment.
233    pub after_index: u32,
234    /// Gap duration in microseconds (negative = overlap).
235    pub gap_us: i64,
236}
237
238impl fmt::Display for GapInfo {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        if self.gap_us >= 0 {
241            write!(
242                f,
243                "Gap of {}us between segments {} and {}",
244                self.gap_us, self.before_index, self.after_index
245            )
246        } else {
247            write!(
248                f,
249                "Overlap of {}us between segments {} and {}",
250                -self.gap_us, self.before_index, self.after_index
251            )
252        }
253    }
254}
255
256// ---------------------------------------------------------------------------
257// MergeResult
258// ---------------------------------------------------------------------------
259
260/// Result of a successful merge operation.
261#[derive(Debug, Clone)]
262pub struct MergeResult {
263    /// Job ID.
264    pub job_id: Uuid,
265    /// Output path.
266    pub output_path: String,
267    /// Total output size in bytes.
268    pub total_bytes: u64,
269    /// Number of segments merged.
270    pub segment_count: u32,
271    /// Total duration in microseconds.
272    pub total_duration_us: i64,
273    /// Detected gaps/overlaps (informational, even if policy allowed them).
274    pub gaps: Vec<GapInfo>,
275    /// Time taken for the merge operation.
276    pub merge_duration: std::time::Duration,
277    /// Strategy used.
278    pub strategy: MergeStrategy,
279}
280
281// ---------------------------------------------------------------------------
282// SegmentMerger
283// ---------------------------------------------------------------------------
284
285/// Validates and merges encoded segments into a final output.
286pub struct SegmentMerger {
287    config: MergeConfig,
288}
289
290impl SegmentMerger {
291    /// Create a new merger with the given configuration.
292    pub fn new(config: MergeConfig) -> Self {
293        Self { config }
294    }
295
296    /// Validate a manifest and detect any gaps/overlaps.
297    pub fn validate(&self, manifest: &SegmentManifest) -> Result<Vec<GapInfo>> {
298        // Check completeness.
299        if !manifest.is_complete() {
300            let missing = manifest.missing_indices();
301            return Err(DistributedError::Segmentation(format!(
302                "Manifest incomplete; missing segments: {:?}",
303                missing
304            )));
305        }
306
307        // Check validation status.
308        if self.config.require_validation {
309            for seg in manifest.segments.values() {
310                if !seg.validated {
311                    return Err(DistributedError::Segmentation(format!(
312                        "Segment {} (index {}) has not been validated",
313                        seg.id, seg.index
314                    )));
315                }
316            }
317        }
318
319        // Detect gaps/overlaps.
320        let ordered = manifest.ordered_segments();
321        let mut gaps = Vec::new();
322
323        for pair in ordered.windows(2) {
324            let prev = pair[0];
325            let next = pair[1];
326            let gap_us = next.start_time_us - prev.end_time_us;
327            if gap_us != 0 {
328                gaps.push(GapInfo {
329                    before_index: prev.index,
330                    after_index: next.index,
331                    gap_us,
332                });
333            }
334        }
335
336        // Enforce gap policy.
337        match self.config.gap_policy {
338            GapPolicy::Strict => {
339                if let Some(g) = gaps.first() {
340                    return Err(DistributedError::Segmentation(format!(
341                        "Strict gap policy violated: {g}"
342                    )));
343                }
344            }
345            GapPolicy::AllowGaps { tolerance_us } => {
346                for g in &gaps {
347                    if g.gap_us.unsigned_abs() > tolerance_us as u64 {
348                        return Err(DistributedError::Segmentation(format!(
349                            "Gap exceeds tolerance ({tolerance_us}us): {g}"
350                        )));
351                    }
352                }
353            }
354            GapPolicy::FillGaps | GapPolicy::Ignore => { /* allow */ }
355        }
356
357        Ok(gaps)
358    }
359
360    /// Merge all segments in the manifest into a final output.
361    ///
362    /// This performs validation, then constructs a [`MergeResult`] describing
363    /// the merged output. In a real system the actual I/O would happen here;
364    /// this implementation computes the metadata.
365    pub fn merge(&self, manifest: &SegmentManifest) -> Result<MergeResult> {
366        let start = Instant::now();
367        let gaps = self.validate(manifest)?;
368
369        let ordered = manifest.ordered_segments();
370        let total_bytes: u64 = ordered.iter().map(|s| s.byte_size).sum();
371        let total_duration_us = ordered.last().map(|s| s.end_time_us).unwrap_or(0)
372            - ordered.first().map(|s| s.start_time_us).unwrap_or(0);
373
374        Ok(MergeResult {
375            job_id: manifest.job_id,
376            output_path: self.config.output_path.clone(),
377            total_bytes,
378            segment_count: ordered.len() as u32,
379            total_duration_us,
380            gaps,
381            merge_duration: start.elapsed(),
382            strategy: self.config.strategy,
383        })
384    }
385
386    /// Get the merge configuration.
387    pub fn config(&self) -> &MergeConfig {
388        &self.config
389    }
390}
391
392// ---------------------------------------------------------------------------
393// Helper: create test segment
394// ---------------------------------------------------------------------------
395
396fn make_test_segment(
397    job_id: Uuid,
398    index: u32,
399    total: u32,
400    start_us: i64,
401    end_us: i64,
402) -> SegmentInfo {
403    SegmentInfo {
404        id: Uuid::new_v4(),
405        job_id,
406        index,
407        total_segments: total,
408        start_time_us: start_us,
409        end_time_us: end_us,
410        byte_size: 1024 * (index as u64 + 1),
411        worker_id: Some(format!("worker-{index}")),
412        storage_path: std::env::temp_dir()
413            .join(format!("oximedia-distributed-segmerge-seg_{index}.ts"))
414            .to_string_lossy()
415            .into_owned(),
416        validated: true,
417    }
418}
419
420// ---------------------------------------------------------------------------
421// Tests
422// ---------------------------------------------------------------------------
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn tmp_str(name: &str) -> String {
429        std::env::temp_dir()
430            .join(format!("oximedia-distributed-segmerge-{name}"))
431            .to_string_lossy()
432            .into_owned()
433    }
434
435    fn setup_manifest(count: u32, gap_us: i64) -> SegmentManifest {
436        let job_id = Uuid::new_v4();
437        let mut manifest = SegmentManifest::new(job_id, count);
438        let segment_len_us: i64 = 5_000_000; // 5 seconds
439
440        for i in 0..count {
441            let start = i as i64 * segment_len_us + i as i64 * gap_us;
442            let end = start + segment_len_us;
443            let seg = make_test_segment(job_id, i, count, start, end);
444            manifest.register_segment(seg).expect("register ok");
445        }
446        manifest
447    }
448
449    #[test]
450    fn test_manifest_creation_and_completeness() {
451        let job_id = Uuid::new_v4();
452        let manifest = SegmentManifest::new(job_id, 4);
453        assert!(!manifest.is_complete());
454        assert_eq!(manifest.missing_indices(), vec![0, 1, 2, 3]);
455    }
456
457    #[test]
458    fn test_manifest_register_and_complete() {
459        let manifest = setup_manifest(3, 0);
460        assert!(manifest.is_complete());
461        assert_eq!(manifest.registered_count(), 3);
462        assert!(manifest.missing_indices().is_empty());
463    }
464
465    #[test]
466    fn test_manifest_rejects_wrong_job_id() {
467        let job_id = Uuid::new_v4();
468        let mut manifest = SegmentManifest::new(job_id, 2);
469        let mut seg = make_test_segment(job_id, 0, 2, 0, 5_000_000);
470        seg.job_id = Uuid::new_v4(); // wrong job
471        let result = manifest.register_segment(seg);
472        assert!(result.is_err());
473    }
474
475    #[test]
476    fn test_manifest_rejects_duplicate_index() {
477        let job_id = Uuid::new_v4();
478        let mut manifest = SegmentManifest::new(job_id, 2);
479        let seg1 = make_test_segment(job_id, 0, 2, 0, 5_000_000);
480        let seg2 = make_test_segment(job_id, 0, 2, 0, 5_000_000);
481        manifest.register_segment(seg1).expect("register ok");
482        let result = manifest.register_segment(seg2);
483        assert!(result.is_err());
484    }
485
486    #[test]
487    fn test_manifest_rejects_out_of_range_index() {
488        let job_id = Uuid::new_v4();
489        let mut manifest = SegmentManifest::new(job_id, 2);
490        let seg = make_test_segment(job_id, 5, 2, 0, 5_000_000);
491        let result = manifest.register_segment(seg);
492        assert!(result.is_err());
493    }
494
495    #[test]
496    fn test_merge_no_gaps() {
497        let manifest = setup_manifest(4, 0);
498        let merger = SegmentMerger::new(MergeConfig {
499            output_path: tmp_str("output.mp4"),
500            ..MergeConfig::default()
501        });
502        let result = merger.merge(&manifest).expect("merge ok");
503        assert_eq!(result.segment_count, 4);
504        assert!(result.gaps.is_empty());
505        assert!(result.total_bytes > 0);
506        assert!(result.total_duration_us > 0);
507    }
508
509    #[test]
510    fn test_merge_small_gap_within_tolerance() {
511        // Default tolerance is 1000us; set gap to 500us
512        let manifest = setup_manifest(3, 500);
513        let merger = SegmentMerger::new(MergeConfig {
514            output_path: tmp_str("output.mp4"),
515            gap_policy: GapPolicy::AllowGaps { tolerance_us: 1000 },
516            ..MergeConfig::default()
517        });
518        let result = merger.merge(&manifest).expect("merge ok");
519        assert_eq!(result.gaps.len(), 2); // gaps between seg 0-1 and 1-2
520    }
521
522    #[test]
523    fn test_merge_strict_rejects_gap() {
524        let manifest = setup_manifest(2, 100);
525        let merger = SegmentMerger::new(MergeConfig {
526            gap_policy: GapPolicy::Strict,
527            output_path: tmp_str("output.mp4"),
528            ..MergeConfig::default()
529        });
530        let result = merger.merge(&manifest);
531        assert!(result.is_err());
532    }
533
534    #[test]
535    fn test_merge_incomplete_manifest_fails() {
536        let job_id = Uuid::new_v4();
537        let mut manifest = SegmentManifest::new(job_id, 3);
538        let seg = make_test_segment(job_id, 0, 3, 0, 5_000_000);
539        manifest.register_segment(seg).expect("register ok");
540
541        let merger = SegmentMerger::new(MergeConfig::default());
542        let result = merger.merge(&manifest);
543        assert!(result.is_err());
544    }
545
546    #[test]
547    fn test_merge_rejects_unvalidated_segment() {
548        let job_id = Uuid::new_v4();
549        let mut manifest = SegmentManifest::new(job_id, 1);
550        let mut seg = make_test_segment(job_id, 0, 1, 0, 5_000_000);
551        seg.validated = false;
552        manifest.register_segment(seg).expect("register ok");
553
554        let merger = SegmentMerger::new(MergeConfig {
555            require_validation: true,
556            output_path: tmp_str("out.mp4"),
557            ..MergeConfig::default()
558        });
559        let result = merger.merge(&manifest);
560        assert!(result.is_err());
561    }
562
563    #[test]
564    fn test_merge_ignores_gaps_with_ignore_policy() {
565        let manifest = setup_manifest(2, 999_999);
566        let merger = SegmentMerger::new(MergeConfig {
567            gap_policy: GapPolicy::Ignore,
568            output_path: tmp_str("output.mp4"),
569            ..MergeConfig::default()
570        });
571        let result = merger.merge(&manifest).expect("merge ok");
572        assert_eq!(result.gaps.len(), 1);
573    }
574
575    #[test]
576    fn test_total_byte_size() {
577        let manifest = setup_manifest(3, 0);
578        // byte_size = 1024*(index+1): 1024 + 2048 + 3072 = 6144
579        assert_eq!(manifest.total_byte_size(), 6144);
580    }
581
582    #[test]
583    fn test_ordered_segments_returns_sorted() {
584        let job_id = Uuid::new_v4();
585        let mut manifest = SegmentManifest::new(job_id, 3);
586        // Insert in reverse order
587        for i in (0..3).rev() {
588            let seg = make_test_segment(job_id, i, 3, i as i64 * 1000, (i as i64 + 1) * 1000);
589            manifest.register_segment(seg).expect("register ok");
590        }
591        let ordered = manifest.ordered_segments();
592        for (i, seg) in ordered.iter().enumerate() {
593            assert_eq!(seg.index, i as u32);
594        }
595    }
596}