use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::camera::Origin;
use crate::time::{Shift, WallTime, Zone};
use crate::{Error, Result};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CameraClock {
pub shift: Shift,
#[serde(skip_serializing_if = "Option::is_none")]
pub zone: Option<Zone>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SyncPoint {
pub a: String,
pub b: String,
}
impl SyncPoint {
pub fn parse_side(s: &str) -> Result<(&str, f64)> {
let (name, at) = s.rsplit_once('@').unwrap_or((s, "0"));
let at = at
.parse()
.map_err(|_| Error::Config(format!("bad sync point {s:?} (want NAME@SECONDS)")))?;
Ok((name, at))
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ClockConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub zone: Option<Zone>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
pub cameras: BTreeMap<String, CameraClock>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sync: Vec<SyncPoint>,
}
impl ClockConfig {
pub fn from_file(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
yaml_serde::from_str(&text).map_err(|e| Error::Config(format!("{}: {e}", path.display())))
}
pub fn set(&mut self, arg: &str) -> Result<()> {
let (cam, rest) = arg
.split_once('=')
.ok_or_else(|| Error::Config(format!("bad --clock {arg:?} (want CAMERA=SHIFT)")))?;
let (shift, zone) = match rest.split_once('@') {
Some((s, z)) => (s, Some(z.parse()?)),
None => (rest, None),
};
let c = self.cameras.entry(cam.to_owned()).or_default();
c.shift = shift.parse()?;
if zone.is_some() {
c.zone = zone;
}
Ok(())
}
pub fn apply(&self, o: &Origin) -> Clock {
let cam = self.cameras.get(&o.camera).cloned().unwrap_or_default();
let shift = cam.shift.secs();
let zone = cam.zone.or(o.camera_zone).or(self.zone);
let (local, utc) = if let Some(t) = o.camera_time {
let local = WallTime(t.0 + shift);
(Some(local), zone.map(|z| z.to_utc(local)))
} else if let Some(u) = o.utc {
let utc = WallTime(u.0 + shift);
(zone.map(|z| z.from_utc(utc)), Some(utc))
} else {
(None, None)
};
Clock {
camera_time: o.camera_time,
camera_zone: o.camera_zone,
shift: cam.shift,
local,
zone,
utc,
source: o.time_source.to_owned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Clock {
#[serde(skip_serializing_if = "Option::is_none")]
pub camera_time: Option<WallTime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub camera_zone: Option<Zone>,
pub shift: Shift,
#[serde(skip_serializing_if = "Option::is_none")]
pub local: Option<WallTime>,
#[serde(skip_serializing_if = "Option::is_none")]
pub zone: Option<Zone>,
#[serde(skip_serializing_if = "Option::is_none")]
pub utc: Option<WallTime>,
pub source: String,
}
impl Clock {
pub fn day(&self) -> String {
self.local
.or(self.utc)
.map_or_else(|| "undated".to_owned(), WallTime::date)
}
pub fn instant(&self) -> Option<WallTime> {
self.utc.or(self.local)
}
}
#[derive(Debug, Clone)]
pub struct Timed<'a> {
pub name: &'a str,
pub camera: &'a str,
pub clock: &'a Clock,
pub duration: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClockIssue {
pub camera: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggest_shift: Option<Shift>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Overlap {
pub a: String,
pub b: String,
pub seconds: f64,
}
const MIN_YEAR: i64 = 2015;
#[allow(clippy::cast_precision_loss)]
pub fn check(cfg: &ClockConfig, clips: &[Timed<'_>], now: WallTime) -> Result<Vec<ClockIssue>> {
let mut issues = Vec::new();
let mut seen_zone: BTreeMap<&str, BTreeMap<String, usize>> = BTreeMap::new();
let mut unset: BTreeMap<&str, usize> = BTreeMap::new();
for c in clips {
if let Some(t) = c.clock.local
&& (t.year() < MIN_YEAR || t.0 > now.0 + 2 * 86_400)
{
*unset.entry(c.camera).or_default() += 1;
}
if let Some(z) = c.clock.zone {
*seen_zone
.entry(c.camera)
.or_default()
.entry(z.to_string())
.or_default() += 1;
}
}
for (cam, n) in unset {
issues.push(ClockIssue {
camera: cam.to_owned(),
message: format!(
"{n} clip(s) dated before {MIN_YEAR} or in the future: clock not set; add a shift"
),
suggest_shift: None,
});
}
let zones: BTreeMap<&str, String> = seen_zone
.iter()
.map(|(cam, z)| {
let top = z.iter().max_by_key(|(_, n)| **n).map(|(z, _)| z.clone());
(*cam, top.unwrap_or_default())
})
.collect();
for (cam, z) in &seen_zone {
if z.len() > 1 {
let list: Vec<_> = z.iter().map(|(z, n)| format!("{z} ({n})")).collect();
issues.push(ClockIssue {
camera: (*cam).to_owned(),
message: format!("clock zone changed between clips: {}", list.join(", ")),
suggest_shift: None,
});
}
}
let distinct: std::collections::BTreeSet<&String> = zones.values().collect();
if distinct.len() > 1 {
let list: Vec<_> = zones.iter().map(|(c, z)| format!("{c} {z}")).collect();
issues.push(ClockIssue {
camera: "*".into(),
message: format!(
"cameras are on different zones: {}; set each camera's zone or fix its clock",
list.join(", ")
),
suggest_shift: None,
});
}
let reference = cfg.reference.clone().unwrap_or_else(|| "iphone".into());
for s in &cfg.sync {
let (an, aat) = SyncPoint::parse_side(&s.a)?;
let (bn, bat) = SyncPoint::parse_side(&s.b)?;
let find = |n: &str| clips.iter().find(|c| c.name == n);
let (Some(a), Some(b)) = (find(an), find(bn)) else {
issues.push(ClockIssue {
camera: "*".into(),
message: format!("sync point {} = {}: clip not in catalog", s.a, s.b),
suggest_shift: None,
});
continue;
};
let (Some(ta), Some(tb)) = (a.clock.instant(), b.clock.instant()) else {
continue;
};
let diff = (ta.0 as f64 + aat) - (tb.0 as f64 + bat);
if diff.abs() < 1.5 {
continue;
}
let (fix, by) = if a.camera == reference {
(b.camera, diff)
} else if b.camera == reference {
(a.camera, -diff)
} else {
(b.camera, diff)
};
let current = cfg.cameras.get(fix).map_or(0.0, |c| c.shift.0);
issues.push(ClockIssue {
camera: fix.to_owned(),
message: format!(
"{} vs {}: clocks disagree by {}",
a.camera,
b.camera,
Shift(diff.abs())
),
suggest_shift: Some(Shift(((current + by) * 10.0).round() / 10.0)),
});
}
Ok(issues)
}
#[allow(clippy::cast_precision_loss)]
pub fn overlaps(clips: &[Timed<'_>]) -> Vec<Overlap> {
let mut spans: Vec<(f64, f64, &Timed<'_>)> = clips
.iter()
.filter_map(|c| {
let t = c.clock.instant()?.0 as f64;
Some((t, t + c.duration?, c))
})
.collect();
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out = Vec::new();
for (i, (s0, e0, a)) in spans.iter().enumerate() {
for (s1, e1, b) in &spans[i + 1..] {
if s1 >= e0 {
break;
}
if a.camera != b.camera {
out.push(Overlap {
a: a.name.to_owned(),
b: b.name.to_owned(),
seconds: (e0.min(*e1) - s0.max(*s1)).max(0.0),
});
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn origin(camera: &str, t: &str, zone: Option<&str>) -> Origin {
Origin {
camera: camera.into(),
camera_time: WallTime::parse(t).map(|(t, _)| t),
camera_zone: zone.map(|z| z.parse().unwrap()),
time_source: "test",
..Origin::default()
}
}
#[test]
fn shift_and_zone() {
let mut cfg = ClockConfig::default();
cfg.set("r10=-2m").unwrap();
cfg.set("pocket=+0h@+00:00").unwrap();
cfg.zone = Some(Zone::UTC);
let c = cfg.apply(&origin("r10", "2026-12-01T10:02:00", None));
assert_eq!(c.local.unwrap().to_string(), "2026-12-01T10:00:00");
assert_eq!(c.utc, c.local);
let c = cfg.apply(&origin("pocket", "2026-12-01T18:00:00", Some("+08:00")));
assert_eq!(c.zone, Some(Zone::UTC));
let c = cfg.apply(&origin("h2e", "2026-12-01T00:30:00", None));
assert_eq!(c.day(), "2026-12-01");
}
#[test]
fn yaml_config() {
let cfg: ClockConfig = yaml_serde::from_str(
"zone: '+00:00'\ncameras:\n r10: { shift: '-2m' }\nsync:\n - { a: 'A.MP4@10', b: 'B.MP4@4' }\n",
)
.unwrap();
assert!((cfg.cameras["r10"].shift.0 + 120.0).abs() < 1e-9);
assert_eq!(cfg.sync.len(), 1);
assert!(yaml_serde::from_str::<ClockConfig>("bogus: 1").is_err());
}
#[test]
fn checks_flag_disagreement() {
let mut cfg = ClockConfig {
zone: Some(Zone::UTC),
reference: Some("pocket".into()),
..ClockConfig::default()
};
cfg.sync.push(SyncPoint {
a: "P.MP4@12".into(),
b: "R.MP4@3".into(),
});
let p = cfg.apply(&origin("pocket", "2026-12-01T10:00:00", None));
let r = cfg.apply(&origin("r10", "2026-12-01T10:02:09", None));
let old = cfg.apply(&origin("pocket", "2000-01-01T00:00:00", None));
let clips = [
Timed {
name: "P.MP4",
camera: "pocket",
clock: &p,
duration: Some(200.0),
},
Timed {
name: "R.MP4",
camera: "r10",
clock: &r,
duration: Some(30.0),
},
Timed {
name: "O.MP4",
camera: "pocket",
clock: &old,
duration: Some(5.0),
},
];
let now = WallTime::parse("2026-12-02T00:00:00").unwrap().0;
let issues = check(&cfg, &clips, now).unwrap();
let sync = issues.iter().find(|i| i.camera == "r10").unwrap();
assert_eq!(sync.suggest_shift, Some(Shift(-120.0)));
assert!(issues.iter().any(|i| i.message.contains("clock not set")));
let ov = overlaps(&clips);
assert_eq!(ov.len(), 1);
assert!((ov[0].seconds - 30.0).abs() < 1e-9);
}
}