//! Layer ▸ Time ▸ Align Video to Data: move a footage layer in time so its frames line up with //! the samples of a data file (telemetry, GPS, sensor logs imported as data footage). //! //! The data's sample with timestamp `T` sits at comp time `dataStart + (T − T₀)` (`T₀` = the //! first sample's timestamp; `dataStart` defaults to 0). A video frame at source time `s` was //! recorded at `V₀ + s`, where `V₀` is the video's start: `videoStart` if given, else the //! footage file's creation date (desktop). Aligning sets the layer's Start Time to //! `dataStart + V₀ − T₀`, so the frame recorded at `T` plays when the data says `T`. //! //! **Supported data** (File ▸ Import as data footage; also in `docs/architecture.md`): //! - JSON: an array of objects, or an object holding such an array (the first array of objects //! found at the top level, e.g. `{"samples": [...]}`); //! - CSV / TSV with a header row (quoted fields allowed). //! //! The time key is `key`, else the first field named `time`, `timestamp`, `t`, `date`, //! `datetime`, `utc`, `gps_time`, `seconds` or `time_s` (any case). Timestamps may be ISO 8601 //! date-times (`2026-05-01T10:15:30.5Z`, offsets `±hh:mm`), times of day (`10:15:30.5`), //! timecode (`10:15:30:12` at the footage's rate), Unix seconds, or Unix milliseconds (numbers //! above 10¹¹). When the data has only times of day, `V₀` is reduced to its UTC time of day. use effectcraft_project::{FootageKind, ItemId, ItemKind}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, bad, f_p, has_layers, layer_p, str_p}; use crate::{EngineError, Result, Session, cmd}; /// A parsed timestamp: seconds, and whether it carries a date (absolute) or is a time of day / /// relative number. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Stamp { pub secs: f64, pub dated: bool, } fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { let y = if m <= 2 { y - 1 } else { y }; let era = y.div_euclid(400); let yoe = y - era * 400; let mp = (m + 9) % 12; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468 } /// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds. fn clock(s: &str, fps: f64) -> Option { let parts: Vec<&str> = s.split([':', ';']).collect(); match parts.as_slice() { [h, m, sec] => Some(h.trim().parse::().ok()? * 3600.0 + m.parse::().ok()? * 60.0 + sec.parse::().ok()?), [h, m, sec, f] => { Some(h.trim().parse::().ok()? * 3600.0 + m.parse::().ok()? * 60.0 + sec.parse::().ok()? + f.parse::().ok()? / fps.max(1.0)) } [m, sec] => Some(m.trim().parse::().ok()? * 60.0 + sec.parse::().ok()?), _ => None, } } /// Parse a timestamp value (see the module docs). `fps` reads timecode frames. pub fn parse_stamp(v: &Value, fps: f64) -> Option { match v { Value::Number(n) => { let x = n.as_f64()?; Some(if x > 1e11 { Stamp { secs: x / 1000.0, dated: true } } else { Stamp { secs: x, dated: x > 1e8 } }) } Value::String(s) => { let s = s.trim(); if let Ok(x) = s.parse::() { return parse_stamp(&json!(x), fps); } // ISO 8601: date [T| ] time [zone]. if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' { let (y, m, d) = (s[0..4].parse::().ok()?, s[5..7].parse::().ok()?, s[8..10].parse::().ok()?); let mut secs = days_from_civil(y, m, d) as f64 * 86_400.0; let rest = s[10..].trim_start_matches(['T', 't', ' ']); if !rest.is_empty() { // Zone: Z or ±hh:mm / ±hhmm. let (time, zone) = match rest.find(['Z', 'z', '+']).or_else(|| rest.rfind('-')) { Some(i) => (&rest[..i], &rest[i..]), None => (rest, ""), }; secs += clock(time, fps)?; if zone.len() > 1 { let sign = if zone.starts_with('-') { -1.0 } else { 1.0 }; let z = zone[1..].replace(':', ""); let (zh, zm) = (z.get(0..2)?.parse::().ok()?, z.get(2..4).and_then(|m| m.parse::().ok()).unwrap_or(0.0)); secs -= sign * (zh * 3600.0 + zm * 60.0); } } return Some(Stamp { secs, dated: true }); } clock(s, fps).map(|secs| Stamp { secs, dated: false }) } _ => None, } } const TIME_KEYS: &[&str] = &["time", "timestamp", "t", "date", "datetime", "utc", "gps_time", "gpstime", "seconds", "time_s", "times"]; /// Split a CSV/TSV line (quotes allowed). fn split_row(line: &str, sep: char) -> Vec { let mut out = vec![]; let mut cur = String::new(); let mut q = false; let mut chars = line.chars().peekable(); while let Some(c) = chars.next() { match c { '"' if q && chars.peek() == Some(&'"') => { cur.push('"'); chars.next(); } '"' => q = !q, c if c == sep && !q => out.push(std::mem::take(&mut cur)), c => cur.push(c), } } out.push(cur); out.into_iter().map(|s| s.trim().to_string()).collect() } /// The time column of data footage: (key used, timestamps in row order). pub fn data_times(text: &str, format: &str, key: Option<&str>) -> std::result::Result<(String, Vec), String> { let pick = |names: &[String]| -> Option { if let Some(k) = key { return names.iter().find(|n| n.eq_ignore_ascii_case(k)).cloned(); } TIME_KEYS.iter().find_map(|t| names.iter().find(|n| n.eq_ignore_ascii_case(t)).cloned()) }; if format == "json" { let v: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("invalid JSON: {e}"))?; let rows = match &v { Value::Array(a) => a.clone(), Value::Object(o) => { o.values().find_map(|x| x.as_array().filter(|a| a.first().is_some_and(Value::is_object)).cloned()).ok_or("no array of samples in the JSON")? } _ => return Err("the JSON holds no samples".into()), }; let names: Vec = rows.first().and_then(Value::as_object).map(|o| o.keys().cloned().collect()).unwrap_or_default(); let k = pick(&names) .ok_or_else(|| format!("no time field (looked for {}); pass `key`", key.map(|k| k.to_string()).unwrap_or_else(|| TIME_KEYS.join(", "))))?; let times = rows.iter().filter_map(|r| r.get(&k).cloned()).collect(); return Ok((k, times)); } let sep = if format == "tsv" { '\t' } else { ',' }; let mut lines = text.trim_start_matches('\u{feff}').lines().filter(|l| !l.trim().is_empty()); let header = split_row(lines.next().ok_or("the file is empty")?, sep); let k = pick(&header) .ok_or_else(|| format!("no time column (looked for {}); pass `key`", key.map(|k| k.to_string()).unwrap_or_else(|| TIME_KEYS.join(", "))))?; let col = header.iter().position(|h| *h == k).unwrap_or(0); let times = lines.filter_map(|l| split_row(l, sep).get(col).map(|c| json!(c))).collect(); Ok((k, times)) } fn data_item(s: &Session, p: &Value) -> Result { let c = "layer.alignVideoToData"; let id = match p.get("data") { Some(Value::Number(n)) => n.as_u64().map(ItemId), Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id), _ => s.project.items.values().find(|i| matches!(&i.kind, ItemKind::Footage(f) if f.kind == FootageKind::Data)).map(|i| i.id), } .ok_or_else(|| bad(c, "no data footage: import a JSON/CSV/TSV file and pass `data`"))?; Ok(id) } fn run(s: &mut Session, p: &Value) -> Result { let c = "layer.alignVideoToData"; let (cid, lid) = layer_p(s, p, c)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; let footage = match layer.source.item().and_then(|i| s.project.item(i)).map(|i| &i.kind) { Some(ItemKind::Footage(f)) if f.kind != FootageKind::Data => f.clone(), _ => return Err(bad(c, "select a footage layer")), }; let fps = footage.frame_rate.as_f64(); let did = data_item(s, p)?; let Some(ItemKind::Footage(df)) = s.project.item(did).map(|i| &i.kind) else { return Err(bad(c, "not data footage")) }; let text = df.data.clone().ok_or_else(|| bad(c, "the data item holds no data"))?; let format = df.codec.to_ascii_lowercase(); let (key, raw) = data_times(&text, &format, str_p(p, "key").filter(|k| !k.is_empty())).map_err(|e| bad(c, e))?; let stamps: Vec = raw.iter().filter_map(|v| parse_stamp(v, fps)).collect(); let first = *stamps.first().ok_or_else(|| bad(c, format!("no readable timestamps in `{key}`")))?; let video = match p.get("videoStart").filter(|v| v.as_str() != Some("")) { Some(v) => parse_stamp(v, fps).ok_or_else(|| bad(c, "videoStart: ISO 8601 date-time, hh:mm:ss, timecode or seconds"))?, None => { let (created, _, _) = crate::media_browser::file_dates(&footage.path); Stamp { secs: created.ok_or_else(|| bad(c, "the footage has no recording time; pass `videoStart`"))? as f64, dated: true } } }; // Compare like with like: times of day when the data has no dates. let v0 = if video.dated && !first.dated { video.secs.rem_euclid(86_400.0) } else { video.secs }; let data_start = f_p(p, "dataStart").unwrap_or(0.0); let fr = comp.frame_rate; let start = fr.snap_nearest(Tick::from_seconds_f64(data_start + v0 - first.secs)); let shift = start - layer.start_time; s.edit("Align Video to Data", None, |proj, _| { let l = super::layer_mut(proj, cid, lid)?; l.start_time += shift; l.in_point += shift; l.out_point += shift; Ok(()) })?; Ok(json!({ "layer": lid.0, "startTime": start.seconds(), "shift": shift.seconds(), "key": key, "samples": stamps.len(), "firstSample": first.secs, "videoStart": v0, "dataStart": data_start, })) } pub fn specs() -> Vec { vec![cmd!( "layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], None, "{layer?, data?: data footage id|name, key?: time field, videoStart?: ISO date-time | hh:mm:ss | timecode | seconds (default: file creation time), dataStart? (comp s of the first sample, 0)}", has_layers, run )] } #[cfg(test)] mod tests { use super::*; #[test] fn parses_timestamps() { let p = |v: Value| parse_stamp(&v, 25.0).unwrap(); assert_eq!(p(json!("1970-01-02T00:00:10Z")).secs, 86_410.0); assert_eq!(p(json!("2000-03-01T00:00:00Z")).secs, 951_868_800.0); assert_eq!(p(json!("1970-01-01T02:00:00+02:00")).secs, 0.0); assert_eq!(p(json!("1970-01-01 01:00:00.5-01:00")).secs, 7200.5); assert_eq!(p(json!("10:00:01.25")), Stamp { secs: 36_001.25, dated: false }); assert_eq!(p(json!("10:00:01:05")).secs, 36_001.2); assert_eq!(p(json!(1_700_000_000_500u64)), Stamp { secs: 1_700_000_000.5, dated: true }); assert_eq!(p(json!(12.5)), Stamp { secs: 12.5, dated: false }); assert_eq!(p(json!("3.5")).secs, 3.5); assert!(parse_stamp(&json!("soon"), 25.0).is_none()); } #[test] fn finds_time_columns() { let (k, t) = data_times("lat,Time,speed\n1,\"10:00:00\",3\n2,10:00:01,4\n", "csv", None).unwrap(); assert_eq!(k, "Time"); assert_eq!(t, vec![json!("10:00:00"), json!("10:00:01")]); let (k, t) = data_times("a\tts\n1\t5\n", "tsv", Some("ts")).unwrap(); assert_eq!((k.as_str(), t), ("ts", vec![json!("5")])); let (k, t) = data_times(r#"{"meta": 1, "samples": [{"timestamp": 3, "v": 1}, {"timestamp": 4}]}"#, "json", None).unwrap(); assert_eq!((k.as_str(), t), ("timestamp", vec![json!(3), json!(4)])); assert!(data_times("x,y\n1,2\n", "csv", None).is_err()); } }