Skip to main content

figif_core/
analysis.rs

1//! Frame analysis and segment detection.
2
3use crate::traits::FrameHasher;
4use crate::types::{AnalyzedFrame, DecodedFrame, Segment};
5
6/// Configuration for segment analysis.
7#[derive(Debug, Clone)]
8pub struct AnalysisConfig {
9    /// Maximum hash distance for frames to be considered similar.
10    pub similarity_threshold: u32,
11    /// Minimum number of consecutive similar frames to form a segment.
12    pub min_segment_frames: usize,
13    /// Whether to mark segments with all identical frames as static.
14    pub detect_static: bool,
15    /// Distance threshold for considering frames identical (for static detection).
16    pub identical_threshold: u32,
17}
18
19impl Default for AnalysisConfig {
20    fn default() -> Self {
21        Self {
22            similarity_threshold: 5,
23            min_segment_frames: 2,
24            detect_static: true,
25            identical_threshold: 0,
26        }
27    }
28}
29
30impl AnalysisConfig {
31    /// Create a new analysis config with default settings.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Set the similarity threshold.
37    pub fn with_similarity_threshold(mut self, threshold: u32) -> Self {
38        self.similarity_threshold = threshold;
39        self
40    }
41
42    /// Set the minimum segment frames.
43    pub fn with_min_segment_frames(mut self, min: usize) -> Self {
44        self.min_segment_frames = min.max(1);
45        self
46    }
47
48    /// Enable or disable static frame detection.
49    pub fn with_static_detection(mut self, enabled: bool) -> Self {
50        self.detect_static = enabled;
51        self
52    }
53
54    /// Set the threshold for identical frame detection.
55    pub fn with_identical_threshold(mut self, threshold: u32) -> Self {
56        self.identical_threshold = threshold;
57        self
58    }
59}
60
61/// Analyze frames and detect segments of similar consecutive frames.
62///
63/// This function:
64/// 1. Computes hashes for all frames using the provided hasher
65/// 2. Compares adjacent frames to find similarity
66/// 3. Groups consecutive similar frames into segments
67///
68/// # Arguments
69///
70/// * `frames` - Decoded frames to analyze
71/// * `hasher` - The hash algorithm to use
72/// * `config` - Analysis configuration
73///
74/// Returns
75///
76/// A tuple of (analyzed_frames, segments)
77pub fn analyze_frames<H: FrameHasher>(
78    frames: Vec<DecodedFrame>,
79    hasher: &H,
80    config: &AnalysisConfig,
81    progress: Option<&(dyn Fn(usize, usize) + Send + Sync)>,
82) -> (Vec<AnalyzedFrame<H::Hash>>, Vec<Segment>) {
83    if frames.is_empty() {
84        return (Vec::new(), Vec::new());
85    }
86
87    let total_frames = frames.len();
88
89    // Compute hashes for all frames
90    let mut analyzed: Vec<AnalyzedFrame<H::Hash>> = Vec::with_capacity(total_frames);
91    for (i, frame) in frames.into_iter().enumerate() {
92        let hash = hasher.hash_frame(&frame.image);
93        analyzed.push(AnalyzedFrame::new(frame, hash));
94
95        if let Some(callback) = progress {
96            callback(i + 1, total_frames);
97        }
98    }
99
100    // Compute distances between adjacent frames and store in each frame
101    let mut distances: Vec<u32> = Vec::with_capacity(analyzed.len().saturating_sub(1));
102    for i in 0..analyzed.len().saturating_sub(1) {
103        let dist = hasher.distance(&analyzed[i].hash, &analyzed[i + 1].hash);
104        distances.push(dist);
105        // Store distance in the NEXT frame (distance from previous)
106        analyzed[i + 1].distance_to_prev = Some(dist);
107    }
108
109    // Detect segments based on similarity threshold
110    let segments = detect_segments(&mut analyzed, &distances, config);
111
112    (analyzed, segments)
113}
114
115/// Analyze frames in parallel (requires `parallel` feature).
116#[cfg(feature = "parallel")]
117pub fn analyze_frames_parallel<H: FrameHasher>(
118    frames: Vec<DecodedFrame>,
119    hasher: &H,
120    config: &AnalysisConfig,
121    progress: Option<&(dyn Fn(usize, usize) + Send + Sync)>,
122) -> (Vec<AnalyzedFrame<H::Hash>>, Vec<Segment>)
123where
124    H::Hash: Send,
125{
126    use rayon::prelude::*;
127    use std::sync::atomic::{AtomicUsize, Ordering};
128
129    if frames.is_empty() {
130        return (Vec::new(), Vec::new());
131    }
132
133    let total_frames = frames.len();
134    let current_frame = AtomicUsize::new(0);
135
136    // Compute hashes for all frames in parallel
137    let mut analyzed: Vec<AnalyzedFrame<H::Hash>> = frames
138        .into_par_iter()
139        .map(|frame| {
140            let hash = hasher.hash_frame(&frame.image);
141
142            if let Some(callback) = progress {
143                let current = current_frame.fetch_add(1, Ordering::Relaxed) + 1;
144                callback(current, total_frames);
145            }
146
147            AnalyzedFrame::new(frame, hash)
148        })
149        .collect();
150
151    // Parallel sort back to original order if needed (map preserves order)
152    // and compute distances between adjacent frames
153    let mut distances: Vec<u32> = Vec::with_capacity(analyzed.len().saturating_sub(1));
154    for i in 0..analyzed.len().saturating_sub(1) {
155        let dist = hasher.distance(&analyzed[i].hash, &analyzed[i + 1].hash);
156        distances.push(dist);
157        // Store distance in the NEXT frame (distance from previous)
158        analyzed[i + 1].distance_to_prev = Some(dist);
159    }
160
161    // Detect segments
162    let segments = detect_segments(&mut analyzed, &distances, config);
163
164    (analyzed, segments)
165}
166
167/// Detect segments from analyzed frames and their distances.
168fn detect_segments<H>(
169    analyzed: &mut [AnalyzedFrame<H>],
170    distances: &[u32],
171    config: &AnalysisConfig,
172) -> Vec<Segment> {
173    let mut segments = Vec::new();
174    let mut segment_id = 0;
175
176    let mut i = 0;
177    while i < analyzed.len() {
178        // Find the end of this segment (consecutive similar frames)
179        let segment_start = i;
180        let mut segment_end = i + 1;
181        let mut total_distance: u64 = 0;
182        let mut distance_count: usize = 0;
183        let mut all_identical = true;
184
185        while segment_end < analyzed.len() {
186            let dist_idx = segment_end - 1;
187            if dist_idx < distances.len() {
188                let dist = distances[dist_idx];
189                if dist <= config.similarity_threshold {
190                    total_distance += dist as u64;
191                    distance_count += 1;
192
193                    // Check if frames are truly identical if threshold is 0
194                    if dist > config.identical_threshold {
195                        all_identical = false;
196                    } else if config.identical_threshold == 0 {
197                        // Double check with raw pixels to avoid hash collisions or
198                        // gradient-only similarities (like uniform color shifts)
199                        if analyzed[segment_end - 1].frame.image
200                            != analyzed[segment_end].frame.image
201                        {
202                            all_identical = false;
203                        }
204                    }
205
206                    segment_end += 1;
207                } else {
208                    break;
209                }
210            } else {
211                break;
212            }
213        }
214
215        let segment_frames = segment_end - segment_start;
216
217        // Create segment if it meets minimum frame requirement
218        if segment_frames >= config.min_segment_frames {
219            // Calculate total duration
220            let total_duration_cs: u16 = analyzed[segment_start..segment_end]
221                .iter()
222                .map(|f| f.delay_cs())
223                .sum();
224
225            // Calculate average distance
226            let avg_distance = if distance_count > 0 {
227                total_distance as f64 / distance_count as f64
228            } else {
229                0.0
230            };
231
232            // Assign segment ID to frames
233            for frame in &mut analyzed[segment_start..segment_end] {
234                frame.segment_id = Some(segment_id);
235            }
236
237            segments.push(Segment {
238                id: segment_id,
239                frame_range: segment_start..segment_end,
240                total_duration_cs,
241                avg_distance,
242                is_static: config.detect_static && all_identical,
243            });
244
245            segment_id += 1;
246        } else {
247            // Single frame or doesn't meet minimum - still create a segment
248            let total_duration_cs = analyzed[segment_start].delay_cs();
249
250            analyzed[segment_start].segment_id = Some(segment_id);
251
252            segments.push(Segment {
253                id: segment_id,
254                frame_range: segment_start..segment_start + 1,
255                total_duration_cs,
256                avg_distance: 0.0,
257                is_static: false,
258            });
259
260            segment_id += 1;
261            segment_end = segment_start + 1;
262        }
263
264        i = segment_end;
265    }
266
267    segments
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_analysis_config_defaults() {
276        let config = AnalysisConfig::default();
277        assert_eq!(config.similarity_threshold, 5);
278        assert_eq!(config.min_segment_frames, 2);
279        assert!(config.detect_static);
280    }
281
282    #[test]
283    fn test_analysis_config_builder() {
284        let config = AnalysisConfig::new()
285            .with_similarity_threshold(10)
286            .with_min_segment_frames(3)
287            .with_static_detection(false);
288
289        assert_eq!(config.similarity_threshold, 10);
290        assert_eq!(config.min_segment_frames, 3);
291        assert!(!config.detect_static);
292    }
293}