1use std::collections::BTreeMap;
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22
23use crate::camera::Origin;
24use crate::time::{Shift, WallTime, Zone};
25use crate::{Error, Result};
26
27#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
28#[serde(default, deny_unknown_fields)]
29pub struct CameraClock {
30 pub shift: Shift,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub zone: Option<Zone>,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct SyncPoint {
40 pub a: String,
41 pub b: String,
42}
43
44impl SyncPoint {
45 pub fn parse_side(s: &str) -> Result<(&str, f64)> {
46 let (name, at) = s.rsplit_once('@').unwrap_or((s, "0"));
47 let at = at
48 .parse()
49 .map_err(|_| Error::Config(format!("bad sync point {s:?} (want NAME@SECONDS)")))?;
50 Ok((name, at))
51 }
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
55#[serde(default, deny_unknown_fields)]
56pub struct ClockConfig {
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub zone: Option<Zone>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub reference: Option<String>,
63 pub cameras: BTreeMap<String, CameraClock>,
64 #[serde(skip_serializing_if = "Vec::is_empty")]
65 pub sync: Vec<SyncPoint>,
66}
67
68impl ClockConfig {
69 pub fn from_file(path: &Path) -> Result<Self> {
70 let text = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
71 yaml_serde::from_str(&text).map_err(|e| Error::Config(format!("{}: {e}", path.display())))
72 }
73
74 pub fn set(&mut self, arg: &str) -> Result<()> {
76 let (cam, rest) = arg
77 .split_once('=')
78 .ok_or_else(|| Error::Config(format!("bad --clock {arg:?} (want CAMERA=SHIFT)")))?;
79 let (shift, zone) = match rest.split_once('@') {
80 Some((s, z)) => (s, Some(z.parse()?)),
81 None => (rest, None),
82 };
83 let c = self.cameras.entry(cam.to_owned()).or_default();
84 c.shift = shift.parse()?;
85 if zone.is_some() {
86 c.zone = zone;
87 }
88 Ok(())
89 }
90
91 pub fn apply(&self, o: &Origin) -> Clock {
93 let cam = self.cameras.get(&o.camera).cloned().unwrap_or_default();
94 let shift = cam.shift.secs();
95 let zone = cam.zone.or(o.camera_zone).or(self.zone);
96 let (local, utc) = if let Some(t) = o.camera_time {
97 let local = WallTime(t.0 + shift);
98 (Some(local), zone.map(|z| z.to_utc(local)))
99 } else if let Some(u) = o.utc {
100 let utc = WallTime(u.0 + shift);
101 (zone.map(|z| z.from_utc(utc)), Some(utc))
102 } else {
103 (None, None)
104 };
105 Clock {
106 camera_time: o.camera_time,
107 camera_zone: o.camera_zone,
108 shift: cam.shift,
109 local,
110 zone,
111 utc,
112 source: o.time_source.to_owned(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct Clock {
120 #[serde(skip_serializing_if = "Option::is_none")]
121 pub camera_time: Option<WallTime>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub camera_zone: Option<Zone>,
124 pub shift: Shift,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub local: Option<WallTime>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub zone: Option<Zone>,
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub utc: Option<WallTime>,
133 pub source: String,
134}
135
136impl Clock {
137 pub fn day(&self) -> String {
139 self.local
140 .or(self.utc)
141 .map_or_else(|| "undated".to_owned(), WallTime::date)
142 }
143
144 pub fn instant(&self) -> Option<WallTime> {
146 self.utc.or(self.local)
147 }
148}
149
150#[derive(Debug, Clone)]
152pub struct Timed<'a> {
153 pub name: &'a str,
154 pub camera: &'a str,
155 pub clock: &'a Clock,
156 pub duration: Option<f64>,
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct ClockIssue {
162 pub camera: String,
163 pub message: String,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 pub suggest_shift: Option<Shift>,
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct Overlap {
172 pub a: String,
173 pub b: String,
174 pub seconds: f64,
175}
176
177const MIN_YEAR: i64 = 2015;
179
180#[allow(clippy::cast_precision_loss)]
182pub fn check(cfg: &ClockConfig, clips: &[Timed<'_>], now: WallTime) -> Result<Vec<ClockIssue>> {
183 let mut issues = Vec::new();
184 let mut seen_zone: BTreeMap<&str, BTreeMap<String, usize>> = BTreeMap::new();
185 let mut unset: BTreeMap<&str, usize> = BTreeMap::new();
186 for c in clips {
187 if let Some(t) = c.clock.local
188 && (t.year() < MIN_YEAR || t.0 > now.0 + 2 * 86_400)
189 {
190 *unset.entry(c.camera).or_default() += 1;
191 }
192 if let Some(z) = c.clock.zone {
193 *seen_zone
194 .entry(c.camera)
195 .or_default()
196 .entry(z.to_string())
197 .or_default() += 1;
198 }
199 }
200 for (cam, n) in unset {
201 issues.push(ClockIssue {
202 camera: cam.to_owned(),
203 message: format!(
204 "{n} clip(s) dated before {MIN_YEAR} or in the future: clock not set; add a shift"
205 ),
206 suggest_shift: None,
207 });
208 }
209 let zones: BTreeMap<&str, String> = seen_zone
210 .iter()
211 .map(|(cam, z)| {
212 let top = z.iter().max_by_key(|(_, n)| **n).map(|(z, _)| z.clone());
213 (*cam, top.unwrap_or_default())
214 })
215 .collect();
216 for (cam, z) in &seen_zone {
217 if z.len() > 1 {
218 let list: Vec<_> = z.iter().map(|(z, n)| format!("{z} ({n})")).collect();
219 issues.push(ClockIssue {
220 camera: (*cam).to_owned(),
221 message: format!("clock zone changed between clips: {}", list.join(", ")),
222 suggest_shift: None,
223 });
224 }
225 }
226 let distinct: std::collections::BTreeSet<&String> = zones.values().collect();
227 if distinct.len() > 1 {
228 let list: Vec<_> = zones.iter().map(|(c, z)| format!("{c} {z}")).collect();
229 issues.push(ClockIssue {
230 camera: "*".into(),
231 message: format!(
232 "cameras are on different zones: {}; set each camera's zone or fix its clock",
233 list.join(", ")
234 ),
235 suggest_shift: None,
236 });
237 }
238 let reference = cfg.reference.clone().unwrap_or_else(|| "iphone".into());
239 for s in &cfg.sync {
240 let (an, aat) = SyncPoint::parse_side(&s.a)?;
241 let (bn, bat) = SyncPoint::parse_side(&s.b)?;
242 let find = |n: &str| clips.iter().find(|c| c.name == n);
243 let (Some(a), Some(b)) = (find(an), find(bn)) else {
244 issues.push(ClockIssue {
245 camera: "*".into(),
246 message: format!("sync point {} = {}: clip not in catalog", s.a, s.b),
247 suggest_shift: None,
248 });
249 continue;
250 };
251 let (Some(ta), Some(tb)) = (a.clock.instant(), b.clock.instant()) else {
252 continue;
253 };
254 let diff = (ta.0 as f64 + aat) - (tb.0 as f64 + bat);
256 if diff.abs() < 1.5 {
257 continue;
258 }
259 let (fix, by) = if a.camera == reference {
261 (b.camera, diff)
262 } else if b.camera == reference {
263 (a.camera, -diff)
264 } else {
265 (b.camera, diff)
266 };
267 let current = cfg.cameras.get(fix).map_or(0.0, |c| c.shift.0);
268 issues.push(ClockIssue {
269 camera: fix.to_owned(),
270 message: format!(
271 "{} vs {}: clocks disagree by {}",
272 a.camera,
273 b.camera,
274 Shift(diff.abs())
275 ),
276 suggest_shift: Some(Shift(((current + by) * 10.0).round() / 10.0)),
277 });
278 }
279 Ok(issues)
280}
281
282#[allow(clippy::cast_precision_loss)]
284pub fn overlaps(clips: &[Timed<'_>]) -> Vec<Overlap> {
285 let mut spans: Vec<(f64, f64, &Timed<'_>)> = clips
286 .iter()
287 .filter_map(|c| {
288 let t = c.clock.instant()?.0 as f64;
289 Some((t, t + c.duration?, c))
290 })
291 .collect();
292 spans.sort_by(|a, b| a.0.total_cmp(&b.0));
293 let mut out = Vec::new();
294 for (i, (s0, e0, a)) in spans.iter().enumerate() {
295 for (s1, e1, b) in &spans[i + 1..] {
296 if s1 >= e0 {
297 break;
298 }
299 if a.camera != b.camera {
300 out.push(Overlap {
301 a: a.name.to_owned(),
302 b: b.name.to_owned(),
303 seconds: (e0.min(*e1) - s0.max(*s1)).max(0.0),
304 });
305 }
306 }
307 }
308 out
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 fn origin(camera: &str, t: &str, zone: Option<&str>) -> Origin {
316 Origin {
317 camera: camera.into(),
318 camera_time: WallTime::parse(t).map(|(t, _)| t),
319 camera_zone: zone.map(|z| z.parse().unwrap()),
320 time_source: "test",
321 ..Origin::default()
322 }
323 }
324
325 #[test]
326 fn shift_and_zone() {
327 let mut cfg = ClockConfig::default();
328 cfg.set("r10=-2m").unwrap();
329 cfg.set("pocket=+0h@+00:00").unwrap();
330 cfg.zone = Some(Zone::UTC);
331 let c = cfg.apply(&origin("r10", "2026-12-01T10:02:00", None));
332 assert_eq!(c.local.unwrap().to_string(), "2026-12-01T10:00:00");
333 assert_eq!(c.utc, c.local);
334 let c = cfg.apply(&origin("pocket", "2026-12-01T18:00:00", Some("+08:00")));
336 assert_eq!(c.zone, Some(Zone::UTC));
337 let c = cfg.apply(&origin("h2e", "2026-12-01T00:30:00", None));
338 assert_eq!(c.day(), "2026-12-01");
339 }
340
341 #[test]
342 fn yaml_config() {
343 let cfg: ClockConfig = yaml_serde::from_str(
344 "zone: '+00:00'\ncameras:\n r10: { shift: '-2m' }\nsync:\n - { a: 'A.MP4@10', b: 'B.MP4@4' }\n",
345 )
346 .unwrap();
347 assert!((cfg.cameras["r10"].shift.0 + 120.0).abs() < 1e-9);
348 assert_eq!(cfg.sync.len(), 1);
349 assert!(yaml_serde::from_str::<ClockConfig>("bogus: 1").is_err());
350 }
351
352 #[test]
353 fn checks_flag_disagreement() {
354 let mut cfg = ClockConfig {
355 zone: Some(Zone::UTC),
356 reference: Some("pocket".into()),
357 ..ClockConfig::default()
358 };
359 cfg.sync.push(SyncPoint {
362 a: "P.MP4@12".into(),
363 b: "R.MP4@3".into(),
364 });
365 let p = cfg.apply(&origin("pocket", "2026-12-01T10:00:00", None));
366 let r = cfg.apply(&origin("r10", "2026-12-01T10:02:09", None));
367 let old = cfg.apply(&origin("pocket", "2000-01-01T00:00:00", None));
368 let clips = [
369 Timed {
370 name: "P.MP4",
371 camera: "pocket",
372 clock: &p,
373 duration: Some(200.0),
374 },
375 Timed {
376 name: "R.MP4",
377 camera: "r10",
378 clock: &r,
379 duration: Some(30.0),
380 },
381 Timed {
382 name: "O.MP4",
383 camera: "pocket",
384 clock: &old,
385 duration: Some(5.0),
386 },
387 ];
388 let now = WallTime::parse("2026-12-02T00:00:00").unwrap().0;
389 let issues = check(&cfg, &clips, now).unwrap();
390 let sync = issues.iter().find(|i| i.camera == "r10").unwrap();
391 assert_eq!(sync.suggest_shift, Some(Shift(-120.0)));
392 assert!(issues.iter().any(|i| i.message.contains("clock not set")));
393 let ov = overlaps(&clips);
394 assert_eq!(ov.len(), 1);
395 assert!((ov[0].seconds - 30.0).abs() < 1e-9);
396 }
397}