Skip to main content

ffcine_triage/
index.rs

1//! The triage index: one JSON file (`index.json`) with every clip's analysis. Fields are only ever
2//! added; readers ignore unknown ones, so an older index stays readable (`version` bumps when a
3//! field changes meaning).
4
5use std::fmt::Write as _;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use crate::Result;
11
12/// Schema version of [`Index`] and of the per-clip cache files.
13pub const VERSION: u32 = 1;
14
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16pub struct Index {
17    pub version: u32,
18    /// The footage directory that was triaged.
19    pub root: PathBuf,
20    pub clips: Vec<Clip>,
21}
22
23/// A time range in seconds from the start of the clip.
24#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
25pub struct Span {
26    pub start: f64,
27    pub end: f64,
28}
29
30impl Span {
31    pub fn len(self) -> f64 {
32        self.end - self.start
33    }
34    pub fn contains(self, t: f64) -> bool {
35        self.start <= t && t < self.end
36    }
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
40pub struct Clip {
41    /// File stem of the source (`DJI_20261028120719_0292_D`), unique within an index.
42    pub id: String,
43    /// The camera original.
44    pub source: PathBuf,
45    /// The file that was decoded for analysis: the DJI `.LRF` proxy when there is one.
46    pub analysed: PathBuf,
47    /// Source size and mtime (Unix seconds): the per-clip cache key.
48    pub size: u64,
49    pub mtime: i64,
50    /// Seconds.
51    pub duration: f64,
52    pub fps: f64,
53    /// Source frame size (not the proxy's), when the source could be probed.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub width: Option<u32>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub height: Option<u32>,
58    /// Local start time (`YYYY-MM-DDTHH:MM:SS`, no zone): the ingest catalog's corrected clock,
59    /// else the camera clock from the DJI file name (which can be unset or on the wrong zone).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub recorded: Option<String>,
62    /// UTC start from the ingest catalog's corrected clock (`YYYY-MM-DDTHH:MM:SS`), when known.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub utc: Option<String>,
65    /// Clips recorded back to back (camera file splits) share a take id: the id of the first.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub take: Option<String>,
68    /// Contact strip JPEG, relative to the index directory.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub strip: Option<String>,
71    /// Time of each strip frame, left to right.
72    #[serde(default)]
73    pub strip_times: Vec<f64>,
74    /// Scene cuts (seconds).
75    #[serde(default)]
76    pub cuts: Vec<f64>,
77    /// Picture activity per second, 0 (frozen) to 1, and mean luma per second (0..1).
78    #[serde(default)]
79    pub activity: Vec<f32>,
80    #[serde(default)]
81    pub luma: Vec<f32>,
82    /// Whether the clip has an audio stream at all.
83    #[serde(default)]
84    pub has_audio: bool,
85    /// Speech map from the energy VAD.
86    #[serde(default)]
87    pub speech: Vec<Span>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub transcript: Option<Transcript>,
90    #[serde(default)]
91    pub flags: Vec<Flag>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub location: Option<Location>,
94}
95
96impl Clip {
97    pub fn speech_seconds(&self) -> f64 {
98        self.speech.iter().map(|s| s.len()).sum()
99    }
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
103pub struct Transcript {
104    /// Detected language (`en`, `tl`, `ja`, ...).
105    pub language: String,
106    /// Model that produced it (`large-v3-turbo`).
107    pub model: String,
108    pub segments: Vec<Segment>,
109}
110
111impl Transcript {
112    pub fn words(&self) -> impl Iterator<Item = &Word> {
113        self.segments.iter().flat_map(|s| &s.words)
114    }
115    pub fn text(&self) -> String {
116        let mut s = String::new();
117        for seg in &self.segments {
118            if !s.is_empty() {
119                s.push(' ');
120            }
121            s.push_str(seg.text.trim());
122        }
123        s
124    }
125}
126
127#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
128pub struct Segment {
129    pub start: f64,
130    pub end: f64,
131    pub text: String,
132    #[serde(default)]
133    pub words: Vec<Word>,
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
137pub struct Word {
138    pub start: f64,
139    pub end: f64,
140    /// As whisper wrote it, usually with a leading space.
141    pub text: String,
142    #[serde(default)]
143    pub prob: f32,
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct Flag {
148    pub kind: FlagKind,
149    /// What and where, for people.
150    pub detail: String,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub span: Option<Span>,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "kebab-case")]
157pub enum FlagKind {
158    /// Left recording: a very long take, usually with a dead tail (`span` = the part to drop).
159    Runaway,
160    /// Minutes with a near-frozen picture and no speech (pocket, table).
161    Dead,
162    /// Mostly black (lens cap, pocket).
163    Dark,
164    /// Under a second and a half: an accidental tap.
165    Blip,
166    NoAudio,
167}
168
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct Location {
171    pub lat: f64,
172    pub lon: f64,
173    /// Place name when the source gave one (`Lisbon, Portugal`).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub name: Option<String>,
176    /// Where it came from (`immich:<asset id>`).
177    pub source: String,
178}
179
180impl Index {
181    pub fn load(path: &Path) -> Result<Self> {
182        let s = std::fs::read_to_string(path)?;
183        Ok(serde_json::from_str(&s)?)
184    }
185
186    pub fn save(&self, path: &Path) -> Result<()> {
187        write_json(path, self)
188    }
189
190    pub fn clip(&self, id: &str) -> Option<&Clip> {
191        self.clips.iter().find(|c| c.id == id)
192    }
193}
194
195/// Writes JSON through a temporary file, so a killed run never leaves a torn file.
196pub(crate) fn write_json<T: Serialize>(path: &Path, v: &T) -> Result<()> {
197    let tmp = path.with_extension("json.tmp");
198    std::fs::write(&tmp, serde_json::to_vec(v)?)?;
199    std::fs::rename(&tmp, path)?;
200    Ok(())
201}
202
203/// `H:MM:SS.ss` (or `M:SS.ss` under an hour).
204pub fn timecode(t: f64) -> String {
205    let t = t.max(0.0);
206    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
207    let whole = t as u64;
208    let frac = t - whole as f64;
209    let (h, m, s) = (whole / 3600, whole / 60 % 60, whole % 60);
210    let mut out = String::new();
211    if h > 0 {
212        let _ = write!(out, "{h}:{m:02}:");
213    } else {
214        let _ = write!(out, "{m}:");
215    }
216    let _ = write!(out, "{:05.2}", s as f64 + frac);
217    out
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn timecodes() {
226        assert_eq!(timecode(0.0), "0:00.00");
227        assert_eq!(timecode(61.5), "1:01.50");
228        assert_eq!(timecode(16_200.25), "4:30:00.25");
229    }
230
231    #[test]
232    fn unknown_fields_are_ignored() {
233        let j = r#"{"version":1,"root":"/x","clips":[{"id":"a","source":"a.mp4","analysed":"a.lrf",
234            "size":1,"mtime":2,"duration":3.0,"fps":24.0,"future":true}]}"#;
235        let ix: Index = serde_json::from_str(j).expect("parse");
236        assert_eq!(ix.clips[0].id, "a");
237        assert_eq!(ix.clips[0].speech, []);
238    }
239}