use crate::scene_cut::SceneCut;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GopPlacementPolicy {
CutsOnly,
CutsPlusFixed,
FixedOnly,
}
#[derive(Debug, Clone)]
pub struct GopConfig {
pub min_gop: u32,
pub max_gop: u32,
pub fixed_period: u32,
pub policy: GopPlacementPolicy,
pub cut_confidence_threshold: f32,
}
impl Default for GopConfig {
fn default() -> Self {
Self {
min_gop: 12,
max_gop: 300,
fixed_period: 120, policy: GopPlacementPolicy::CutsPlusFixed,
cut_confidence_threshold: 0.4,
}
}
}
impl GopConfig {
#[must_use]
pub fn new(min_gop: u32, max_gop: u32, fixed_period: u32) -> Self {
Self {
min_gop,
max_gop,
fixed_period,
..Self::default()
}
}
#[must_use]
pub fn with_policy(mut self, policy: GopPlacementPolicy) -> Self {
self.policy = policy;
self
}
#[must_use]
pub fn with_cut_threshold(mut self, threshold: f32) -> Self {
self.cut_confidence_threshold = threshold;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IFramePoint {
pub frame: u64,
pub reason: IFrameReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IFrameReason {
StreamStart,
SceneCut,
Periodic,
}
impl IFramePoint {
#[must_use]
pub fn new(frame: u64, reason: IFrameReason) -> Self {
Self { frame, reason }
}
}
#[must_use]
pub fn compute_i_frame_placements(
total_frames: u64,
cuts: &[SceneCut],
config: &GopConfig,
) -> Vec<IFramePoint> {
if total_frames == 0 {
return Vec::new();
}
let mut candidates: Vec<IFramePoint> = Vec::new();
candidates.push(IFramePoint::new(0, IFrameReason::StreamStart));
if config.policy != GopPlacementPolicy::FixedOnly {
for cut in cuts {
if cut.confidence >= config.cut_confidence_threshold && cut.frame < total_frames {
candidates.push(IFramePoint::new(cut.frame, IFrameReason::SceneCut));
}
}
}
if config.policy != GopPlacementPolicy::CutsOnly && config.fixed_period > 0 {
let period = u64::from(config.fixed_period);
let mut f = period;
while f < total_frames {
let already = candidates.iter().any(|p| p.frame == f);
if !already {
candidates.push(IFramePoint::new(f, IFrameReason::Periodic));
}
f += period;
}
}
candidates.sort_by_key(|p| p.frame);
candidates.dedup_by_key(|p| p.frame);
let min_gop = u64::from(config.min_gop);
let mut filtered: Vec<IFramePoint> = Vec::with_capacity(candidates.len());
let mut last_iframe: u64 = 0;
for point in candidates {
let gap = point.frame.saturating_sub(last_iframe);
let is_first = point.frame == 0;
if is_first || gap >= min_gop {
last_iframe = point.frame;
filtered.push(point);
}
}
let max_gop = u64::from(config.max_gop);
let mut result: Vec<IFramePoint> = Vec::with_capacity(filtered.len() * 2);
for i in 0..filtered.len() {
result.push(filtered[i].clone());
let next_iframe = if i + 1 < filtered.len() {
filtered[i + 1].frame
} else {
total_frames
};
let gap = next_iframe.saturating_sub(filtered[i].frame);
if gap > max_gop {
let mut fill = filtered[i].frame + max_gop;
while fill < next_iframe {
result.push(IFramePoint::new(fill, IFrameReason::Periodic));
fill += max_gop;
}
}
}
result
}
#[derive(Debug, Clone)]
pub struct GopStats {
pub i_frame_count: u32,
pub mean_gop: f64,
pub min_gop: u64,
pub max_gop: u64,
pub scene_cut_count: u32,
pub periodic_count: u32,
}
#[must_use]
pub fn gop_stats(placements: &[IFramePoint], total_frames: u64) -> GopStats {
if placements.is_empty() {
return GopStats {
i_frame_count: 0,
mean_gop: 0.0,
min_gop: 0,
max_gop: 0,
scene_cut_count: 0,
periodic_count: 0,
};
}
let mut gop_lengths: Vec<u64> = Vec::with_capacity(placements.len());
for i in 0..placements.len() {
let end = if i + 1 < placements.len() {
placements[i + 1].frame
} else {
total_frames
};
gop_lengths.push(end.saturating_sub(placements[i].frame));
}
let min_gop = gop_lengths.iter().copied().min().unwrap_or(0);
let max_gop = gop_lengths.iter().copied().max().unwrap_or(0);
let sum: u64 = gop_lengths.iter().sum();
let mean_gop = if gop_lengths.is_empty() {
0.0
} else {
sum as f64 / gop_lengths.len() as f64
};
let scene_cut_count = placements
.iter()
.filter(|p| p.reason == IFrameReason::SceneCut)
.count() as u32;
let periodic_count = placements
.iter()
.filter(|p| p.reason == IFrameReason::Periodic)
.count() as u32;
GopStats {
i_frame_count: placements.len() as u32,
mean_gop,
min_gop,
max_gop,
scene_cut_count,
periodic_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene_cut::{CutDetectionMethod, SceneCut};
fn make_cut(frame: u64, confidence: f32) -> SceneCut {
SceneCut::new(frame, confidence, CutDetectionMethod::Histogram)
}
#[test]
fn test_default_config() {
let c = GopConfig::default();
assert_eq!(c.min_gop, 12);
assert_eq!(c.max_gop, 300);
assert_eq!(c.fixed_period, 120);
assert_eq!(c.policy, GopPlacementPolicy::CutsPlusFixed);
assert!((c.cut_confidence_threshold - 0.4).abs() < 1e-6);
}
#[test]
fn test_config_builder_methods() {
let c = GopConfig::new(10, 200, 60)
.with_policy(GopPlacementPolicy::CutsOnly)
.with_cut_threshold(0.6);
assert_eq!(c.min_gop, 10);
assert_eq!(c.max_gop, 200);
assert_eq!(c.fixed_period, 60);
assert_eq!(c.policy, GopPlacementPolicy::CutsOnly);
assert!((c.cut_confidence_threshold - 0.6).abs() < 1e-6);
}
#[test]
fn test_empty_stream_returns_empty() {
let placements = compute_i_frame_placements(0, &[], &GopConfig::default());
assert!(placements.is_empty());
}
#[test]
fn test_stream_start_always_present() {
let config = GopConfig::default();
let placements = compute_i_frame_placements(100, &[], &config);
assert!(!placements.is_empty());
assert_eq!(placements[0].frame, 0);
assert_eq!(placements[0].reason, IFrameReason::StreamStart);
}
#[test]
fn test_scene_cut_inserted() {
let config = GopConfig::new(5, 1000, 500)
.with_policy(GopPlacementPolicy::CutsOnly)
.with_cut_threshold(0.4);
let cuts = vec![make_cut(50, 0.9)];
let placements = compute_i_frame_placements(200, &cuts, &config);
assert!(placements.iter().any(|p| p.frame == 50 && p.reason == IFrameReason::SceneCut));
}
#[test]
fn test_low_confidence_cut_ignored() {
let config = GopConfig::new(5, 1000, 500)
.with_policy(GopPlacementPolicy::CutsOnly)
.with_cut_threshold(0.8);
let cuts = vec![make_cut(50, 0.3)];
let placements = compute_i_frame_placements(200, &cuts, &config);
assert!(!placements.iter().any(|p| p.frame == 50));
}
#[test]
fn test_periodic_insertion() {
let config = GopConfig::new(5, 10000, 60)
.with_policy(GopPlacementPolicy::FixedOnly);
let placements = compute_i_frame_placements(200, &[], &config);
assert!(placements.iter().any(|p| p.frame == 60));
assert!(placements.iter().any(|p| p.frame == 120));
assert!(placements.iter().any(|p| p.frame == 180));
}
#[test]
fn test_min_gop_enforced() {
let config = GopConfig::new(20, 1000, 500)
.with_policy(GopPlacementPolicy::CutsOnly)
.with_cut_threshold(0.4);
let cuts = vec![make_cut(50, 0.9), make_cut(55, 0.95)];
let placements = compute_i_frame_placements(200, &cuts, &config);
let frames: Vec<u64> = placements.iter().map(|p| p.frame).collect();
let fifty = frames.contains(&50);
let fiftyfive = frames.contains(&55);
assert!(fifty || fiftyfive, "at least one of the cuts should appear");
assert!(!(fifty && fiftyfive), "both cannot appear due to min_gop");
}
#[test]
fn test_max_gop_enforced() {
let config = GopConfig::new(1, 30, 10000)
.with_policy(GopPlacementPolicy::CutsOnly)
.with_cut_threshold(0.4);
let placements = compute_i_frame_placements(300, &[], &config);
assert!(
placements.iter().any(|p| p.frame == 30 && p.reason == IFrameReason::Periodic),
"synthetic I-frame at 30 expected"
);
}
#[test]
fn test_no_duplicate_frames() {
let config = GopConfig::new(5, 1000, 60)
.with_policy(GopPlacementPolicy::CutsPlusFixed);
let cuts = vec![make_cut(60, 0.9)];
let placements = compute_i_frame_placements(200, &cuts, &config);
let mut frames: Vec<u64> = placements.iter().map(|p| p.frame).collect();
frames.sort_unstable();
let count_before = frames.len();
frames.dedup();
assert_eq!(frames.len(), count_before, "no duplicate frames");
}
#[test]
fn test_gop_stats_empty() {
let s = gop_stats(&[], 100);
assert_eq!(s.i_frame_count, 0);
assert_eq!(s.mean_gop, 0.0);
}
#[test]
fn test_gop_stats_single_iframe() {
let pts = vec![IFramePoint::new(0, IFrameReason::StreamStart)];
let s = gop_stats(&pts, 60);
assert_eq!(s.i_frame_count, 1);
assert_eq!(s.max_gop, 60);
}
#[test]
fn test_gop_stats_counts_reasons() {
let pts = vec![
IFramePoint::new(0, IFrameReason::StreamStart),
IFramePoint::new(30, IFrameReason::SceneCut),
IFramePoint::new(60, IFrameReason::Periodic),
IFramePoint::new(90, IFrameReason::SceneCut),
];
let s = gop_stats(&pts, 120);
assert_eq!(s.scene_cut_count, 2);
assert_eq!(s.periodic_count, 1);
assert_eq!(s.i_frame_count, 4);
}
#[test]
fn test_gop_stats_mean_gop() {
let pts = vec![
IFramePoint::new(0, IFrameReason::StreamStart),
IFramePoint::new(60, IFrameReason::Periodic),
];
let s = gop_stats(&pts, 120);
assert!((s.mean_gop - 60.0).abs() < 1e-9);
}
#[test]
fn test_full_pipeline_cuts_plus_fixed() {
let config = GopConfig::new(10, 300, 90).with_policy(GopPlacementPolicy::CutsPlusFixed);
let cuts = vec![make_cut(45, 0.85), make_cut(180, 0.92)];
let placements = compute_i_frame_placements(300, &cuts, &config);
for w in placements.windows(2) {
assert!(w[0].frame < w[1].frame, "placements should be sorted");
}
let stats = gop_stats(&placements, 300);
assert!(
stats.max_gop <= 300,
"max GOP {max} should be <= 300",
max = stats.max_gop
);
assert!(
stats.min_gop >= 10 || stats.i_frame_count == 1,
"min GOP {min} should be >= 10",
min = stats.min_gop
);
}
}