Skip to main content

ffcine_ingest/
clock.rs

1//! Per-camera clock and zone fixes, and the checks that catch cameras whose clocks disagree.
2//!
3//! Files are never rewritten: the fix lives in the config and is applied to catalog timestamps
4//! (and to the day folder chosen at ingest).
5//!
6//! ```yaml
7//! # clocks.yaml
8//! zone: "+00:00"          # the trip's local time (UTC here), for cameras that don't say
9//! reference: iphone       # the camera other clocks are checked against (network time)
10//! cameras:
11//!   pocket: { shift: "+0h" }
12//!   r10: { shift: "-2m" }             # its clock runs 2 min fast
13//!   h2e: { shift: "+0s", zone: "+00:00" }
14//! sync:                               # the same moment seen by two cameras (clap, audio sync)
15//!   - { a: "DJI_20261201101500_0003_D.MP4@12.4", b: "MVI_0042.MP4@3.1" }
16//! ```
17
18use 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    /// Added to the camera's clock (`-2m` for a clock 2 minutes fast).
31    pub shift: Shift,
32    /// The zone the camera's clock was set to, overriding what the file says.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub zone: Option<Zone>,
35}
36
37/// A moment seen by two clips: `NAME@SECONDS` each (file name, seconds into the clip).
38#[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    /// Default zone for cameras whose files don't record one.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub zone: Option<Zone>,
60    /// Camera to measure the others against in the checks (default: `iphone` if present).
61    #[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    /// Adds `CAMERA=SHIFT[@ZONE]` from the command line (`r10=-2m`, `pocket=+0h@+00:00`).
75    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    /// Corrected time for a file from `camera`.
92    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/// A clip's timestamps: what the camera said, and the corrected values.
118#[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    /// Corrected local wall time (the day folder and the edit's notion of "when").
126    #[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    /// Corrected UTC instant, when the zone is known: what cross-camera ordering uses.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub utc: Option<WallTime>,
133    pub source: String,
134}
135
136impl Clock {
137    /// `YYYY-MM-DD`, or `undated`.
138    pub fn day(&self) -> String {
139        self.local
140            .or(self.utc)
141            .map_or_else(|| "undated".to_owned(), WallTime::date)
142    }
143
144    /// A timeline position for ordering across cameras: UTC if known, else local.
145    pub fn instant(&self) -> Option<WallTime> {
146        self.utc.or(self.local)
147    }
148}
149
150/// A clip as the checks see it.
151#[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/// Clock problems for the catalog report.
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct ClockIssue {
162    pub camera: String,
163    pub message: String,
164    /// The `cameras.<id>.shift` that would fix it, when measurable.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub suggest_shift: Option<Shift>,
167}
168
169/// Two clips from different cameras recorded at the same time: where audio sync should look.
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct Overlap {
172    pub a: String,
173    pub b: String,
174    pub seconds: f64,
175}
176
177/// Plausible recording years; outside means the clock was reset (DJI's year 2000).
178const MIN_YEAR: i64 = 2015;
179
180/// Runs the clock checks: unset clocks, zone disagreement, sync points.
181#[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        // The same moment: a.start + aat == b.start + bat on a true clock.
255        let diff = (ta.0 as f64 + aat) - (tb.0 as f64 + bat);
256        if diff.abs() < 1.5 {
257            continue;
258        }
259        // Fix the camera that isn't the reference; b by default.
260        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/// Clips from different cameras whose corrected time ranges overlap.
283#[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        // DJI file says +08:00 (clock left on another zone); the config pins it to UTC.
335        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        // The clap is 12 s into the pocket clip and 3 s into the r10 clip; r10's clock reads
360        // 2 min fast, so its clip seems to start 2 min later than it did.
361        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}