Skip to main content

figif_core/
pipeline.rs

1//! High-level processing pipeline.
2
3use crate::analysis::{AnalysisConfig, analyze_frames};
4use crate::decoders::BufferedDecoder;
5use crate::error::Result;
6use crate::hashers::DHasher;
7use crate::segment::{apply_operations, apply_segment_operations};
8use crate::traits::{FrameHasher, GifDecoder, GifEncoder};
9use crate::types::{
10    AnalyzedFrame, EncodableFrame, EncodeConfig, FrameOps, GifMetadata, Segment, SegmentOp,
11    SegmentOps,
12};
13use std::path::Path;
14use std::sync::Arc;
15
16#[cfg(feature = "parallel")]
17use crate::analysis::analyze_frames_parallel;
18
19/// Callback for reporting progress during analysis.
20/// First argument is current frame count, second is total frame count.
21pub type ProgressCallback = Arc<dyn Fn(usize, usize) + Send + Sync>;
22
23/// Main entry point for GIF analysis and manipulation.
24///
25/// `Figif` provides a builder-style API for configuring the analysis pipeline
26/// and processing GIF files.
27///
28/// # Type Parameter
29///
30/// `H` is the hasher type used for frame comparison. Defaults to [`DHasher`].
31///
32/// # Example
33///
34/// ```ignore
35/// use figif_core::{Figif, SegmentOp};
36/// use std::collections::HashMap;
37///
38/// // Create analyzer with default settings
39/// let figif = Figif::new();
40///
41/// // Analyze a GIF
42/// let analysis = figif.analyze_file("demo.gif")?;
43///
44/// // Print segment info
45/// for segment in &analysis.segments {
46///     println!("Segment {}: {} frames, {}ms",
47///         segment.id,
48///         segment.frame_count(),
49///         segment.duration_ms());
50/// }
51///
52/// // Define operations
53/// let mut ops = HashMap::new();
54/// ops.insert(1, SegmentOp::Collapse { delay_cs: 50 });
55///
56/// // Export with operations applied
57/// use figif_core::encoders::StandardEncoder;
58/// let encoder = StandardEncoder::new();
59/// let bytes = analysis.export(&encoder, &ops, &EncodeConfig::default())?;
60/// ```
61#[derive(Clone)]
62pub struct Figif<H: FrameHasher = DHasher> {
63    hasher: H,
64    config: AnalysisConfig,
65    decoder: BufferedDecoder,
66    progress_callback: Option<ProgressCallback>,
67}
68
69impl<H: FrameHasher + std::fmt::Debug> std::fmt::Debug for Figif<H> {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("Figif")
72            .field("hasher", &self.hasher)
73            .field("config", &self.config)
74            .field("decoder", &self.decoder)
75            .field(
76                "progress_callback",
77                &self.progress_callback.as_ref().map(|_| "Some(callback)"),
78            )
79            .finish()
80    }
81}
82
83impl Default for Figif<DHasher> {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl Figif<DHasher> {
90    /// Create a new Figif instance with default settings.
91    ///
92    /// Uses DHasher for frame comparison with sensible defaults.
93    pub fn new() -> Self {
94        Self {
95            hasher: DHasher::new(),
96            config: AnalysisConfig::default(),
97            decoder: BufferedDecoder::new(),
98            progress_callback: None,
99        }
100    }
101}
102
103impl<H: FrameHasher> Figif<H> {
104    /// Replace the hasher with a different implementation.
105    ///
106    /// # Example
107    ///
108    /// ```ignore
109    /// use figif_core::{Figif, hashers::PHasher};
110    ///
111    /// let figif = Figif::new().with_hasher(PHasher::new());
112    /// ```
113    pub fn with_hasher<H2: FrameHasher>(self, hasher: H2) -> Figif<H2> {
114        Figif {
115            hasher,
116            config: self.config,
117            decoder: self.decoder,
118            progress_callback: self.progress_callback,
119        }
120    }
121
122    /// Set a progress callback for analysis.
123    pub fn with_progress_callback(mut self, callback: ProgressCallback) -> Self {
124        self.progress_callback = Some(callback);
125        self
126    }
127
128    /// Set the similarity threshold for segment detection.
129    ///
130    /// Lower values are more strict (fewer frames considered similar).
131    /// Default is 5 (suitable for most use cases).
132    pub fn similarity_threshold(mut self, threshold: u32) -> Self {
133        self.config.similarity_threshold = threshold;
134        self
135    }
136
137    /// Set the minimum number of frames to form a segment.
138    ///
139    /// Default is 2.
140    pub fn min_segment_frames(mut self, min: usize) -> Self {
141        self.config.min_segment_frames = min.max(1);
142        self
143    }
144
145    /// Enable or disable static frame detection.
146    ///
147    /// When enabled, segments with all identical frames are marked as static.
148    /// Default is true.
149    pub fn detect_static(mut self, enabled: bool) -> Self {
150        self.config.detect_static = enabled;
151        self
152    }
153
154    /// Set the threshold for identical frame detection.
155    ///
156    /// Frames with distance <= this threshold are considered identical.
157    /// Default is 0 (exact matches only).
158    pub fn identical_threshold(mut self, threshold: u32) -> Self {
159        self.config.identical_threshold = threshold;
160        self
161    }
162
163    /// Set a memory limit for the decoder.
164    pub fn memory_limit(mut self, limit: usize) -> Self {
165        self.decoder = self.decoder.with_memory_limit(limit);
166        self
167    }
168
169    /// Get the current analysis configuration.
170    pub fn config(&self) -> &AnalysisConfig {
171        &self.config
172    }
173
174    /// Get a reference to the hasher.
175    pub fn hasher(&self) -> &H {
176        &self.hasher
177    }
178
179    /// Analyze a GIF file.
180    ///
181    /// Loads the GIF, computes frame hashes, and detects segments.
182    pub fn analyze_file(&self, path: impl AsRef<Path>) -> Result<Analysis<H::Hash>>
183    where
184        H::Hash: Send,
185    {
186        #[cfg(feature = "parallel")]
187        {
188            self.analyze_file_parallel(path)
189        }
190        #[cfg(not(feature = "parallel"))]
191        {
192            let frames: Vec<_> = self
193                .decoder
194                .decode_file(path)?
195                .collect::<Result<Vec<_>>>()?;
196            self.analyze_frames(frames)
197        }
198    }
199
200    /// Analyze a GIF from bytes.
201    pub fn analyze_bytes(&self, data: &[u8]) -> Result<Analysis<H::Hash>>
202    where
203        H::Hash: Send,
204    {
205        #[cfg(feature = "parallel")]
206        {
207            self.analyze_bytes_parallel(data)
208        }
209        #[cfg(not(feature = "parallel"))]
210        {
211            let frames: Vec<_> = self
212                .decoder
213                .decode_bytes(data)?
214                .collect::<Result<Vec<_>>>()?;
215            self.analyze_frames(frames)
216        }
217    }
218
219    /// Analyze a GIF from pre-decoded frames.
220    pub fn analyze_frames(
221        &self,
222        frames: Vec<crate::types::DecodedFrame>,
223    ) -> Result<Analysis<H::Hash>> {
224        // Get metadata from frames
225        let metadata = if frames.is_empty() {
226            GifMetadata {
227                width: 0,
228                height: 0,
229                frame_count: 0,
230                total_duration_ms: 0,
231                has_transparency: false,
232                loop_count: crate::types::LoopCount::Infinite,
233                global_palette: None,
234            }
235        } else {
236            let (width, height) = frames[0].image.dimensions();
237            let total_duration_ms: u64 = frames.iter().map(|f| f.delay_ms() as u64).sum();
238            GifMetadata {
239                width: width as u16,
240                height: height as u16,
241                frame_count: frames.len(),
242                total_duration_ms,
243                has_transparency: true, // Conservative assumption
244                loop_count: crate::types::LoopCount::Infinite,
245                global_palette: None,
246            }
247        };
248
249        // Analyze frames
250        let progress = self
251            .progress_callback
252            .as_ref()
253            .map(|c| c.as_ref() as &(dyn Fn(usize, usize) + Send + Sync));
254        let (analyzed_frames, segments) =
255            analyze_frames(frames, &self.hasher, &self.config, progress);
256
257        Ok(Analysis {
258            metadata,
259            frames: analyzed_frames,
260            segments,
261        })
262    }
263
264    /// Analyze a GIF file using parallel processing.
265    ///
266    /// Requires the `parallel` feature.
267    #[cfg(feature = "parallel")]
268    pub fn analyze_file_parallel(&self, path: impl AsRef<Path>) -> Result<Analysis<H::Hash>>
269    where
270        H::Hash: Send,
271    {
272        let frames: Vec<_> = self
273            .decoder
274            .decode_file(path)?
275            .collect::<Result<Vec<_>>>()?;
276        self.analyze_frames_parallel(frames)
277    }
278
279    /// Analyze a GIF from bytes using parallel processing.
280    #[cfg(feature = "parallel")]
281    pub fn analyze_bytes_parallel(&self, data: &[u8]) -> Result<Analysis<H::Hash>>
282    where
283        H::Hash: Send,
284    {
285        let frames: Vec<_> = self
286            .decoder
287            .decode_bytes(data)?
288            .collect::<Result<Vec<_>>>()?;
289        self.analyze_frames_parallel(frames)
290    }
291
292    /// Analyze pre-decoded frames using parallel processing.
293    #[cfg(feature = "parallel")]
294    pub fn analyze_frames_parallel(
295        &self,
296        frames: Vec<crate::types::DecodedFrame>,
297    ) -> Result<Analysis<H::Hash>>
298    where
299        H::Hash: Send,
300    {
301        let metadata = if frames.is_empty() {
302            GifMetadata {
303                width: 0,
304                height: 0,
305                frame_count: 0,
306                total_duration_ms: 0,
307                has_transparency: false,
308                loop_count: crate::types::LoopCount::Infinite,
309                global_palette: None,
310            }
311        } else {
312            let (width, height) = frames[0].image.dimensions();
313            let total_duration_ms: u64 = frames.iter().map(|f| f.delay_ms() as u64).sum();
314            GifMetadata {
315                width: width as u16,
316                height: height as u16,
317                frame_count: frames.len(),
318                total_duration_ms,
319                has_transparency: true,
320                loop_count: crate::types::LoopCount::Infinite,
321                global_palette: None,
322            }
323        };
324
325        let progress = self
326            .progress_callback
327            .as_ref()
328            .map(|c| c.as_ref() as &(dyn Fn(usize, usize) + Send + Sync));
329        let (analyzed_frames, segments) =
330            analyze_frames_parallel(frames, &self.hasher, &self.config, progress);
331
332        Ok(Analysis {
333            metadata,
334            frames: analyzed_frames,
335            segments,
336        })
337    }
338}
339
340/// Result of GIF analysis containing frames and segments.
341#[derive(Debug, Clone)]
342pub struct Analysis<H> {
343    /// Metadata about the original GIF.
344    pub metadata: GifMetadata,
345    /// Analyzed frames with hashes and segment assignments.
346    pub frames: Vec<AnalyzedFrame<H>>,
347    /// Detected segments.
348    pub segments: Vec<Segment>,
349}
350
351impl<H: Clone + Sync + Send> Analysis<H> {
352    /// Get the number of frames.
353    pub fn frame_count(&self) -> usize {
354        self.frames.len()
355    }
356
357    /// Get the number of segments.
358    pub fn segment_count(&self) -> usize {
359        self.segments.len()
360    }
361
362    /// Get the total duration in milliseconds.
363    pub fn total_duration_ms(&self) -> u64 {
364        self.metadata.total_duration_ms
365    }
366
367    /// Get segments that are marked as static.
368    pub fn static_segments(&self) -> Vec<&Segment> {
369        self.segments.iter().filter(|s| s.is_static).collect()
370    }
371
372    /// Apply segment operations and get encodable frames.
373    ///
374    /// Operations not in the map default to `Keep`.
375    pub fn apply_operations(&self, ops: &SegmentOps) -> Vec<EncodableFrame> {
376        apply_segment_operations(&self.frames, &self.segments, ops)
377    }
378
379    /// Apply operations and export using the specified encoder.
380    pub fn export<E: GifEncoder>(
381        &self,
382        encoder: &E,
383        ops: &SegmentOps,
384        config: &EncodeConfig,
385    ) -> Result<Vec<u8>> {
386        let frames = self.apply_operations(ops);
387        encoder.encode(&frames, config)
388    }
389
390    /// Apply operations and export to a file.
391    pub fn export_to_file<E: GifEncoder>(
392        &self,
393        encoder: &E,
394        ops: &SegmentOps,
395        path: impl AsRef<Path>,
396        config: &EncodeConfig,
397    ) -> Result<()> {
398        let frames = self.apply_operations(ops);
399        encoder.encode_to_file(&frames, path, config)
400    }
401
402    /// Apply both segment and frame operations and get encodable frames.
403    ///
404    /// This is the enhanced version that handles frame-level operations like
405    /// individual frame removal and segment splitting.
406    ///
407    /// Operations not in the maps default to `Keep`.
408    pub fn apply_all_operations(
409        &self,
410        segment_ops: &SegmentOps,
411        frame_ops: &FrameOps,
412    ) -> Vec<EncodableFrame> {
413        apply_operations(&self.frames, &self.segments, segment_ops, frame_ops)
414    }
415
416    /// Calculate the resulting frame count and duration without cloning images.
417    ///
418    /// Returns (total_frames, total_duration_ms).
419    pub fn calculate_impact(&self, segment_ops: &SegmentOps, frame_ops: &FrameOps) -> (usize, u64) {
420        let (frames, cs) = crate::segment::dry_run_all_operations(
421            &self.frames,
422            &self.segments,
423            segment_ops,
424            frame_ops,
425        );
426        (frames, cs * 10)
427    }
428
429    /// Apply both segment and frame operations and export using the specified encoder.
430    pub fn export_with_frame_ops<E: GifEncoder>(
431        &self,
432        encoder: &E,
433        segment_ops: &SegmentOps,
434        frame_ops: &FrameOps,
435        config: &EncodeConfig,
436    ) -> Result<Vec<u8>> {
437        let frames = self.apply_all_operations(segment_ops, frame_ops);
438        encoder.encode(&frames, config)
439    }
440
441    /// Apply both segment and frame operations and export to a file.
442    pub fn export_to_file_with_frame_ops<E: GifEncoder>(
443        &self,
444        encoder: &E,
445        segment_ops: &SegmentOps,
446        frame_ops: &FrameOps,
447        path: impl AsRef<Path>,
448        config: &EncodeConfig,
449    ) -> Result<()> {
450        let frames = self.apply_all_operations(segment_ops, frame_ops);
451        encoder.encode_to_file(&frames, path, config)
452    }
453
454    /// Logically split segments at frames marked with `FrameOp::SplitAfter`.
455    ///
456    /// This returns a new `Analysis` where segments have been partitioned
457    /// based on the split points. This allows applying different segment-level
458    /// operations to the newly created parts.
459    pub fn split_segments(&self, frame_ops: &FrameOps) -> Analysis<H> {
460        let (new_frames, new_segments) =
461            crate::segment::split_segments_at_points(&self.frames, &self.segments, frame_ops);
462
463        Analysis {
464            metadata: self.metadata.clone(),
465            frames: new_frames,
466            segments: new_segments,
467        }
468    }
469
470    /// Get frames as encodable without any operations applied.
471    pub fn as_encodable(&self) -> Vec<EncodableFrame> {
472        self.frames
473            .iter()
474            .map(|f| EncodableFrame::from_decoded(&f.frame))
475            .collect()
476    }
477
478    // =========================================================================
479    // Fluent Segment Selectors
480    // =========================================================================
481
482    /// Select all static segments (pauses/duplicate frames).
483    ///
484    /// Returns a [`SegmentSelector`] that can be filtered and operated on.
485    ///
486    /// # Example
487    ///
488    /// ```ignore
489    /// // Cap all pauses to 300ms
490    /// let ops = analysis.pauses().cap(300);
491    ///
492    /// // Collapse only long pauses
493    /// let ops = analysis.pauses()
494    ///     .longer_than(500)
495    ///     .collapse(200);
496    /// ```
497    pub fn pauses(&self) -> crate::selector::SegmentSelector<'_> {
498        let segments = self.segments.iter().filter(|s| s.is_static).collect();
499        crate::selector::SegmentSelector::new(segments)
500    }
501
502    /// Select all motion segments (non-static, actually changing content).
503    ///
504    /// # Example
505    ///
506    /// ```ignore
507    /// // Speed up all motion by 1.5x
508    /// let ops = analysis.motion().speed_up(1.5);
509    /// ```
510    pub fn motion(&self) -> crate::selector::SegmentSelector<'_> {
511        let segments = self.segments.iter().filter(|s| !s.is_static).collect();
512        crate::selector::SegmentSelector::new(segments)
513    }
514
515    /// Select all segments.
516    ///
517    /// # Example
518    ///
519    /// ```ignore
520    /// // Speed up everything by 2x
521    /// let ops = analysis.all().speed_up(2.0);
522    /// ```
523    pub fn all(&self) -> crate::selector::SegmentSelector<'_> {
524        let segments = self.segments.iter().collect();
525        crate::selector::SegmentSelector::new(segments)
526    }
527
528    /// Select a single segment by ID.
529    ///
530    /// # Example
531    ///
532    /// ```ignore
533    /// // Remove segment 5
534    /// let ops = analysis.segment(5).remove();
535    /// ```
536    pub fn segment(&self, id: usize) -> crate::selector::SegmentSelector<'_> {
537        let segments = self.segments.iter().filter(|s| s.id == id).collect();
538        crate::selector::SegmentSelector::new(segments)
539    }
540
541    /// Select multiple segments by ID.
542    ///
543    /// # Example
544    ///
545    /// ```ignore
546    /// // Collapse specific segments
547    /// let ops = analysis.segments_by_id(&[1, 3, 5]).collapse(100);
548    /// ```
549    pub fn segments_by_id(&self, ids: &[usize]) -> crate::selector::SegmentSelector<'_> {
550        let segments = self
551            .segments
552            .iter()
553            .filter(|s| ids.contains(&s.id))
554            .collect();
555        crate::selector::SegmentSelector::new(segments)
556    }
557
558    /// Select segments by frame index range.
559    ///
560    /// Selects any segment that overlaps with the given frame range.
561    ///
562    /// # Example
563    ///
564    /// ```ignore
565    /// // Speed up the first 100 frames
566    /// let ops = analysis.frames_range(0..100).speed_up(2.0);
567    /// ```
568    pub fn frames_range(
569        &self,
570        range: std::ops::Range<usize>,
571    ) -> crate::selector::SegmentSelector<'_> {
572        let segments = self
573            .segments
574            .iter()
575            .filter(|s| {
576                // Check if segment overlaps with range
577                s.frame_range.start < range.end && s.frame_range.end > range.start
578            })
579            .collect();
580        crate::selector::SegmentSelector::new(segments)
581    }
582
583    // ========================================================================
584    // Convenience methods for common operations (legacy, use selectors instead)
585    // ========================================================================
586
587    /// Cap all static segments (pause points) to a maximum duration.
588    ///
589    /// Static segments longer than `max_ms` will be collapsed to a single
590    /// frame with that duration. Shorter segments are unchanged.
591    ///
592    /// # Example
593    ///
594    /// ```ignore
595    /// // Make all pauses last at most 300ms
596    /// let ops = analysis.cap_pauses(300);
597    /// let frames = analysis.apply_operations(&ops);
598    /// ```
599    pub fn cap_pauses(&self, max_ms: u32) -> SegmentOps {
600        let max_cs = (max_ms / 10) as u16;
601        let mut ops = SegmentOps::new();
602
603        for segment in &self.segments {
604            if segment.is_static && segment.total_duration_cs > max_cs {
605                ops.insert(segment.id, SegmentOp::Collapse { delay_cs: max_cs });
606            }
607        }
608
609        ops
610    }
611
612    /// Collapse all static segments to a fixed duration.
613    ///
614    /// Every static segment becomes a single frame with the specified delay,
615    /// regardless of its original length.
616    ///
617    /// # Example
618    ///
619    /// ```ignore
620    /// // Make all pauses exactly 200ms
621    /// let ops = analysis.collapse_all_pauses(200);
622    /// ```
623    pub fn collapse_all_pauses(&self, duration_ms: u32) -> SegmentOps {
624        let delay_cs = (duration_ms / 10) as u16;
625        let mut ops = SegmentOps::new();
626
627        for segment in &self.segments {
628            if segment.is_static {
629                ops.insert(segment.id, SegmentOp::Collapse { delay_cs });
630            }
631        }
632
633        ops
634    }
635
636    /// Remove all static segments longer than the specified duration.
637    ///
638    /// # Example
639    ///
640    /// ```ignore
641    /// // Remove any pause longer than 2 seconds
642    /// let ops = analysis.remove_long_pauses(2000);
643    /// ```
644    pub fn remove_long_pauses(&self, min_ms: u32) -> SegmentOps {
645        let min_cs = (min_ms / 10) as u16;
646        let mut ops = SegmentOps::new();
647
648        for segment in &self.segments {
649            if segment.is_static && segment.total_duration_cs >= min_cs {
650                ops.insert(segment.id, SegmentOp::Remove);
651            }
652        }
653
654        ops
655    }
656
657    /// Speed up all static segments by a factor.
658    ///
659    /// A factor of 2.0 makes pauses twice as fast (half duration).
660    /// A factor of 0.5 makes them half as fast (double duration).
661    ///
662    /// # Example
663    ///
664    /// ```ignore
665    /// // Make all pauses 3x faster
666    /// let ops = analysis.speed_up_pauses(3.0);
667    /// ```
668    pub fn speed_up_pauses(&self, factor: f64) -> SegmentOps {
669        let mut ops = SegmentOps::new();
670
671        for segment in &self.segments {
672            if segment.is_static {
673                ops.insert(
674                    segment.id,
675                    SegmentOp::Scale {
676                        factor: 1.0 / factor,
677                    },
678                );
679            }
680        }
681
682        ops
683    }
684
685    /// Speed up the entire GIF by a factor.
686    ///
687    /// Affects all segments, not just static ones.
688    ///
689    /// # Example
690    ///
691    /// ```ignore
692    /// // Make everything 1.5x faster
693    /// let ops = analysis.speed_up_all(1.5);
694    /// ```
695    pub fn speed_up_all(&self, factor: f64) -> SegmentOps {
696        let mut ops = SegmentOps::new();
697
698        for segment in &self.segments {
699            ops.insert(
700                segment.id,
701                SegmentOp::Scale {
702                    factor: 1.0 / factor,
703                },
704            );
705        }
706
707        ops
708    }
709
710    /// Create operations that optimize the GIF for a target duration.
711    ///
712    /// This will collapse/remove static segments as needed to try to
713    /// reach the target duration. Non-static segments are preserved.
714    ///
715    /// Returns `None` if the target is not achievable (non-static content
716    /// already exceeds the target).
717    ///
718    /// # Example
719    ///
720    /// ```ignore
721    /// // Try to get the GIF under 30 seconds
722    /// if let Some(ops) = analysis.target_duration(30_000) {
723    ///     let frames = analysis.apply_operations(&ops);
724    /// }
725    /// ```
726    pub fn target_duration(&self, target_ms: u64) -> Option<SegmentOps> {
727        let current_ms = self.total_duration_ms();
728        if current_ms <= target_ms {
729            return Some(SegmentOps::new()); // Already under target
730        }
731
732        // Calculate how much we need to remove
733        let to_remove_ms = current_ms - target_ms;
734
735        // Calculate total static duration
736        let static_duration_ms: u64 = self
737            .segments
738            .iter()
739            .filter(|s| s.is_static)
740            .map(|s| s.duration_ms() as u64)
741            .sum();
742
743        // If we can't remove enough from static segments, return None
744        if static_duration_ms < to_remove_ms {
745            return None;
746        }
747
748        // Sort static segments by duration (longest first)
749        let mut static_segs: Vec<_> = self.segments.iter().filter(|s| s.is_static).collect();
750        static_segs.sort_by(|a, b| b.total_duration_cs.cmp(&a.total_duration_cs));
751
752        let mut ops = SegmentOps::new();
753        let mut removed_ms: u64 = 0;
754        let min_pause_ms = 100; // Keep at least 100ms per pause
755
756        for segment in static_segs {
757            if removed_ms >= to_remove_ms {
758                break;
759            }
760
761            let segment_ms = segment.duration_ms() as u64;
762            let can_remove = segment_ms.saturating_sub(min_pause_ms as u64);
763
764            if can_remove > 0 {
765                let to_remove_from_this = can_remove.min(to_remove_ms - removed_ms);
766                let new_duration_ms = segment_ms - to_remove_from_this;
767
768                if new_duration_ms <= min_pause_ms as u64 {
769                    // Collapse to minimum
770                    ops.insert(
771                        segment.id,
772                        SegmentOp::Collapse {
773                            delay_cs: (min_pause_ms / 10) as u16,
774                        },
775                    );
776                } else {
777                    // Set specific duration
778                    ops.insert(
779                        segment.id,
780                        SegmentOp::Collapse {
781                            delay_cs: (new_duration_ms / 10) as u16,
782                        },
783                    );
784                }
785
786                removed_ms += to_remove_from_this;
787            }
788        }
789
790        Some(ops)
791    }
792
793    /// Merge multiple operation sets, with later ops overriding earlier ones.
794    ///
795    /// # Example
796    ///
797    /// ```ignore
798    /// let base = analysis.cap_pauses(500);
799    /// let extra = analysis.speed_up_all(1.2);
800    /// let combined = Analysis::<H>::merge_ops(&[base, extra]);
801    /// ```
802    pub fn merge_ops(op_sets: &[SegmentOps]) -> SegmentOps {
803        let mut merged = SegmentOps::new();
804        for ops in op_sets {
805            merged.extend(ops.iter().map(|(k, v)| (*k, v.clone())));
806        }
807        merged
808    }
809}