Skip to main content

oximedia_clips/
camera_metadata.rs

1//! Camera-specific metadata for video clips.
2//!
3//! Stores lens, ISO, aperture, shutter speed and focal-length information
4//! captured at the time of recording. This data is typically sourced from
5//! camera roll XML exports (e.g. Arri ALEXAMetadata, REDCODE companion XML)
6//! or from camera-embedded MXF/MOV metadata atoms.
7
8#![allow(dead_code)]
9
10use crate::clip::ClipId;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14/// Camera-specific technical metadata for a single clip.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct CameraMetadata {
17    /// Lens make and model, e.g. `"Zeiss Supreme Prime 50mm T1.5"`.
18    pub lens: Option<String>,
19
20    /// ISO sensitivity used during recording, e.g. `800`.
21    pub iso: Option<u32>,
22
23    /// Aperture as a floating-point f-stop value, e.g. `2.8` for f/2.8.
24    pub aperture: Option<f32>,
25
26    /// Shutter speed expressed as a fraction or angle string,
27    /// e.g. `"1/48"` or `"172.8°"`.
28    pub shutter_speed: Option<String>,
29
30    /// Recorded focal length in millimetres, e.g. `50.0`.
31    pub focal_length_mm: Option<f32>,
32
33    /// Camera body make, e.g. `"ARRI"`.
34    pub camera_make: Option<String>,
35
36    /// Camera body model, e.g. `"ALEXA Mini LF"`.
37    pub camera_model: Option<String>,
38}
39
40impl CameraMetadata {
41    /// Creates an empty `CameraMetadata`.
42    #[must_use]
43    pub fn new() -> Self {
44        Self {
45            lens: None,
46            iso: None,
47            aperture: None,
48            shutter_speed: None,
49            focal_length_mm: None,
50            camera_make: None,
51            camera_model: None,
52        }
53    }
54
55    /// Builder-style setter for `lens`.
56    #[must_use]
57    pub fn with_lens(mut self, lens: impl Into<String>) -> Self {
58        self.lens = Some(lens.into());
59        self
60    }
61
62    /// Builder-style setter for `iso`.
63    #[must_use]
64    pub fn with_iso(mut self, iso: u32) -> Self {
65        self.iso = Some(iso);
66        self
67    }
68
69    /// Builder-style setter for `aperture`.
70    #[must_use]
71    pub fn with_aperture(mut self, aperture: f32) -> Self {
72        self.aperture = Some(aperture);
73        self
74    }
75
76    /// Builder-style setter for `shutter_speed`.
77    #[must_use]
78    pub fn with_shutter_speed(mut self, speed: impl Into<String>) -> Self {
79        self.shutter_speed = Some(speed.into());
80        self
81    }
82
83    /// Builder-style setter for `focal_length_mm`.
84    #[must_use]
85    pub fn with_focal_length_mm(mut self, mm: f32) -> Self {
86        self.focal_length_mm = Some(mm);
87        self
88    }
89
90    /// Builder-style setter for `camera_make`.
91    #[must_use]
92    pub fn with_camera_make(mut self, make: impl Into<String>) -> Self {
93        self.camera_make = Some(make.into());
94        self
95    }
96
97    /// Builder-style setter for `camera_model`.
98    #[must_use]
99    pub fn with_camera_model(mut self, model: impl Into<String>) -> Self {
100        self.camera_model = Some(model.into());
101        self
102    }
103
104    /// Returns `true` if no fields are set.
105    #[must_use]
106    pub fn is_empty(&self) -> bool {
107        self.lens.is_none()
108            && self.iso.is_none()
109            && self.aperture.is_none()
110            && self.shutter_speed.is_none()
111            && self.focal_length_mm.is_none()
112            && self.camera_make.is_none()
113            && self.camera_model.is_none()
114    }
115
116    /// Returns an exposure-value (EV) hint based on ISO and aperture when both
117    /// are present.  EV = log2(aperture² / (ISO / 100)).  This is a coarse
118    /// indication; shutter speed is not included here because it is stored as
119    /// a string and would require additional parsing.
120    #[must_use]
121    pub fn approximate_ev(&self) -> Option<f32> {
122        let iso = self.iso? as f32;
123        let ap = self.aperture?;
124        // Prevent log of non-positive values
125        if iso <= 0.0 || ap <= 0.0 {
126            return None;
127        }
128        let ev = (ap * ap / (iso / 100.0)).log2();
129        Some(ev)
130    }
131}
132
133impl Default for CameraMetadata {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139/// Trait that allows attaching and retrieving `CameraMetadata` to any type
140/// that is indexed by `ClipId`.
141///
142/// Implementors provide storage for an arbitrary number of clip camera records.
143pub trait CameraMetadataExt {
144    /// Attaches camera metadata to a clip.
145    fn set_camera_metadata(&mut self, clip_id: ClipId, meta: CameraMetadata);
146
147    /// Retrieves camera metadata for a clip.
148    fn camera_metadata(&self, clip_id: &ClipId) -> Option<&CameraMetadata>;
149
150    /// Removes and returns the camera metadata for a clip.
151    fn remove_camera_metadata(&mut self, clip_id: &ClipId) -> Option<CameraMetadata>;
152}
153
154/// In-memory store implementing `CameraMetadataExt` keyed by `ClipId`.
155#[derive(Debug, Default)]
156pub struct CameraMetadataStore {
157    inner: HashMap<ClipId, CameraMetadata>,
158}
159
160impl CameraMetadataStore {
161    /// Creates an empty store.
162    #[must_use]
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Returns the number of entries.
168    #[must_use]
169    pub fn len(&self) -> usize {
170        self.inner.len()
171    }
172
173    /// Returns `true` if the store has no entries.
174    #[must_use]
175    pub fn is_empty(&self) -> bool {
176        self.inner.is_empty()
177    }
178
179    /// Returns an iterator over all `(ClipId, CameraMetadata)` pairs.
180    pub fn iter(&self) -> impl Iterator<Item = (&ClipId, &CameraMetadata)> {
181        self.inner.iter()
182    }
183}
184
185impl CameraMetadataExt for CameraMetadataStore {
186    fn set_camera_metadata(&mut self, clip_id: ClipId, meta: CameraMetadata) {
187        self.inner.insert(clip_id, meta);
188    }
189
190    fn camera_metadata(&self, clip_id: &ClipId) -> Option<&CameraMetadata> {
191        self.inner.get(clip_id)
192    }
193
194    fn remove_camera_metadata(&mut self, clip_id: &ClipId) -> Option<CameraMetadata> {
195        self.inner.remove(clip_id)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::clip::ClipId;
203
204    #[test]
205    fn test_camera_metadata_default_is_empty() {
206        let meta = CameraMetadata::new();
207        assert!(meta.is_empty());
208    }
209
210    #[test]
211    fn test_camera_metadata_builder() {
212        let meta = CameraMetadata::new()
213            .with_lens("Zeiss 50mm T1.5")
214            .with_iso(800)
215            .with_aperture(2.8)
216            .with_shutter_speed("1/48")
217            .with_focal_length_mm(50.0)
218            .with_camera_make("ARRI")
219            .with_camera_model("ALEXA Mini LF");
220
221        assert_eq!(meta.lens.as_deref(), Some("Zeiss 50mm T1.5"));
222        assert_eq!(meta.iso, Some(800));
223        assert!((meta.aperture.expect("aperture should be set") - 2.8).abs() < 1e-5);
224        assert_eq!(meta.shutter_speed.as_deref(), Some("1/48"));
225        assert!((meta.focal_length_mm.expect("focal_length_mm should be set") - 50.0).abs() < 1e-5);
226        assert_eq!(meta.camera_make.as_deref(), Some("ARRI"));
227        assert_eq!(meta.camera_model.as_deref(), Some("ALEXA Mini LF"));
228        assert!(!meta.is_empty());
229    }
230
231    #[test]
232    fn test_camera_metadata_approximate_ev_some() {
233        // aperture 2.8, iso 800 → EV = log2(7.84 / 8) ≈ −0.028
234        let meta = CameraMetadata::new().with_aperture(2.8).with_iso(800);
235        let ev = meta.approximate_ev();
236        assert!(ev.is_some());
237        let ev_val = ev.expect("EV should be computed when aperture and ISO are set");
238        assert!(ev_val > -1.0 && ev_val < 1.0);
239    }
240
241    #[test]
242    fn test_camera_metadata_approximate_ev_none_when_missing_fields() {
243        let meta_no_iso = CameraMetadata::new().with_aperture(2.8);
244        assert!(meta_no_iso.approximate_ev().is_none());
245
246        let meta_no_ap = CameraMetadata::new().with_iso(800);
247        assert!(meta_no_ap.approximate_ev().is_none());
248    }
249
250    #[test]
251    fn test_camera_metadata_store_set_get() {
252        let mut store = CameraMetadataStore::new();
253        let id = ClipId::new();
254        let meta = CameraMetadata::new().with_iso(400);
255
256        store.set_camera_metadata(id, meta.clone());
257        assert_eq!(store.len(), 1);
258
259        let retrieved = store.camera_metadata(&id).expect("should be present");
260        assert_eq!(retrieved.iso, Some(400));
261    }
262
263    #[test]
264    fn test_camera_metadata_store_remove() {
265        let mut store = CameraMetadataStore::new();
266        let id = ClipId::new();
267        store.set_camera_metadata(id, CameraMetadata::new().with_iso(100));
268
269        let removed = store.remove_camera_metadata(&id);
270        assert!(removed.is_some());
271        assert!(store.is_empty());
272    }
273
274    #[test]
275    fn test_camera_metadata_store_missing_key() {
276        let store = CameraMetadataStore::new();
277        let id = ClipId::new();
278        assert!(store.camera_metadata(&id).is_none());
279    }
280
281    #[test]
282    fn test_camera_metadata_overwrite() {
283        let mut store = CameraMetadataStore::new();
284        let id = ClipId::new();
285
286        store.set_camera_metadata(id, CameraMetadata::new().with_iso(200));
287        store.set_camera_metadata(id, CameraMetadata::new().with_iso(3200));
288
289        assert_eq!(store.len(), 1);
290        assert_eq!(
291            store
292                .camera_metadata(&id)
293                .expect("overwritten metadata should be present")
294                .iso,
295            Some(3200)
296        );
297    }
298}