Skip to main content

ffcine_timeline/
metadata.rs

1//! The `ffcine` metadata namespace.
2//!
3//! ```json
4//! "metadata": {
5//!   "ffcine": {
6//!     "v": 1,
7//!     "id": "payday",
8//!     "foreign": { "edit_yaml": { ... }, "resolve": { ... } }
9//!   },
10//!   "cdl": { ... }
11//! }
12//! ```
13//!
14//! - `v`: the namespace version, on the timeline only ([`FFCINE_VERSION`]).
15//! - `id`: a stable id per clip so editorial changes made elsewhere merge back by id.
16//! - `foreign.<app>`: everything an importer didn't map, verbatim (`docs/import.md`).
17//! - `ffcine` effects (`Effect.1` with `effect_name = "ffcine.<domain>.<name>"`) carry their
18//!   parameters here too, tagged `"schema": "ffcine.<domain>.<name>/<major>"`.
19
20use serde::Serialize;
21use serde::de::DeserializeOwned;
22use serde_json::{Map, Value};
23
24use super::schema::{
25    Clip, Effect, ExternalReference, Gap, GeneratorReference, GenericEffect, Item, LinearTimeWarp,
26    Marker, MediaReference, MissingReference, Stack, Timeline, Track, Transition,
27};
28use ffcine_core::{Error, Result};
29
30/// The metadata key ffcine owns.
31pub const FFCINE: &str = "ffcine";
32/// Version of the `ffcine` namespace layout, written as `metadata.ffcine.v` on the timeline.
33pub const FFCINE_VERSION: u64 = 1;
34
35/// Access to an OTIO object's `metadata`, and the `ffcine` namespace inside it.
36pub trait HasMetadata {
37    fn metadata(&self) -> &Map<String, Value>;
38    fn metadata_mut(&mut self) -> &mut Map<String, Value>;
39
40    /// `metadata.ffcine`, if it is an object.
41    fn ffcine(&self) -> Option<&Map<String, Value>> {
42        self.metadata().get(FFCINE).and_then(Value::as_object)
43    }
44
45    /// `metadata.ffcine`, created if missing (a non-object value there is replaced).
46    fn ffcine_mut(&mut self) -> &mut Map<String, Value> {
47        object_entry(self.metadata_mut(), FFCINE)
48    }
49
50    /// `metadata.ffcine.<key>`.
51    fn ffcine_value(&self, key: &str) -> Option<&Value> {
52        self.ffcine()?.get(key)
53    }
54
55    /// `metadata.ffcine.<key>` decoded as `T`; `None` if absent.
56    fn ffcine_get<T: DeserializeOwned>(&self, key: &str) -> Option<Result<T>> {
57        self.ffcine_value(key)
58            .map(|v| T::deserialize(v).map_err(Error::from))
59    }
60
61    /// Sets `metadata.ffcine.<key>`.
62    fn ffcine_set<T: Serialize + ?Sized>(&mut self, key: &str, value: &T) -> Result<()> {
63        let v = serde_json::to_value(value)?;
64        self.ffcine_mut().insert(key.to_owned(), v);
65        Ok(())
66    }
67
68    /// `metadata.ffcine.id`.
69    fn ffcine_id(&self) -> Option<&str> {
70        self.ffcine_value("id").and_then(Value::as_str)
71    }
72
73    fn set_ffcine_id(&mut self, id: impl Into<String>) {
74        self.ffcine_mut()
75            .insert("id".into(), Value::String(id.into()));
76    }
77
78    /// `metadata.ffcine.foreign.<app>`.
79    fn foreign(&self, app: &str) -> Option<&Value> {
80        self.ffcine_value("foreign")?.as_object()?.get(app)
81    }
82
83    /// Sets `metadata.ffcine.foreign.<app>`.
84    fn set_foreign(&mut self, app: &str, value: Value) {
85        object_entry(self.ffcine_mut(), "foreign").insert(app.to_owned(), value);
86    }
87
88    /// Inserts `key: value` into the object at `metadata.ffcine.foreign.<app>`.
89    fn insert_foreign(&mut self, app: &str, key: &str, value: Value) {
90        let foreign = object_entry(self.ffcine_mut(), "foreign");
91        object_entry(foreign, app).insert(key.to_owned(), value);
92    }
93
94    /// Moves an OTIO adapter's namespace (`metadata.<key>`, e.g. `Resolve_OTIO`) to
95    /// `metadata.ffcine.foreign.<app>`. Returns whether there was one.
96    fn move_to_foreign(&mut self, key: &str, app: &str) -> bool {
97        match self.metadata_mut().shift_remove(key) {
98            Some(v) => {
99                self.set_foreign(app, v);
100                true
101            }
102            None => false,
103        }
104    }
105}
106
107/// `map[key]` as an object, created (or replaced, if not an object) on demand.
108fn object_entry<'a>(map: &'a mut Map<String, Value>, key: &str) -> &'a mut Map<String, Value> {
109    let slot = map.entry(key).or_insert_with(|| Value::Object(Map::new()));
110    if !slot.is_object() {
111        *slot = Value::Object(Map::new());
112    }
113    match slot {
114        Value::Object(m) => m,
115        _ => unreachable!("just made an object"),
116    }
117}
118
119macro_rules! has_metadata {
120    ($($t:ty),*) => {$(
121        impl HasMetadata for $t {
122            fn metadata(&self) -> &Map<String, Value> {
123                &self.metadata
124            }
125            fn metadata_mut(&mut self) -> &mut Map<String, Value> {
126                &mut self.metadata
127            }
128        }
129    )*};
130}
131
132has_metadata!(
133    Timeline,
134    Stack,
135    Track,
136    Clip,
137    Gap,
138    Transition,
139    GenericEffect,
140    LinearTimeWarp,
141    Marker,
142    ExternalReference,
143    MissingReference,
144    GeneratorReference
145);
146
147/// Empty map handed out for `Unknown` objects without metadata.
148static EMPTY: std::sync::LazyLock<Map<String, Value>> = std::sync::LazyLock::new(Map::new);
149
150/// Unknown objects expose their `metadata` if it is an object (created on mutable access).
151macro_rules! has_metadata_enum {
152    ($t:ident { $($v:ident),* }) => {
153        impl HasMetadata for $t {
154            fn metadata(&self) -> &Map<String, Value> {
155                match self {
156                    $($t::$v(x) => &x.metadata,)*
157                    $t::Unknown(m) => m.get("metadata").and_then(Value::as_object).unwrap_or(&EMPTY),
158                }
159            }
160            fn metadata_mut(&mut self) -> &mut Map<String, Value> {
161                match self {
162                    $($t::$v(x) => &mut x.metadata,)*
163                    $t::Unknown(m) => object_entry(m, "metadata"),
164                }
165            }
166        }
167    };
168}
169
170has_metadata_enum!(Item {
171    Clip,
172    Gap,
173    Stack,
174    Track,
175    Transition
176});
177has_metadata_enum!(Effect {
178    Effect,
179    LinearTimeWarp,
180    FreezeFrame
181});
182has_metadata_enum!(MediaReference {
183    External,
184    Missing,
185    Generator
186});
187
188/// `"ffcine.color.grade/1"` -> `("ffcine.color.grade", 1)`.
189pub fn split_ffcine_schema(s: &str) -> Option<(&str, u32)> {
190    let (name, major) = s.rsplit_once('/')?;
191    Some((name, major.parse().ok()?))
192}
193
194impl GenericEffect {
195    /// An ffcine effect: `effect_name` is the schema name, `params` go to `metadata.ffcine`
196    /// with `"schema": schema`. `schema` is `"ffcine.<domain>.<name>/<major>"`.
197    pub fn ffcine(schema: &str, params: Map<String, Value>) -> Self {
198        let effect_name = split_ffcine_schema(schema).map_or(schema, |(n, _)| n);
199        let short = effect_name.rsplit('.').next().unwrap_or(effect_name);
200        let mut e = GenericEffect::new(short, effect_name);
201        let ns = e.ffcine_mut();
202        ns.insert("schema".into(), schema.into());
203        ns.extend(params);
204        e
205    }
206
207    /// `metadata.ffcine.schema` split into name and major version.
208    pub fn ffcine_schema(&self) -> Option<(&str, u32)> {
209        self.ffcine_value("schema")
210            .and_then(Value::as_str)
211            .and_then(split_ffcine_schema)
212    }
213
214    /// `metadata.ffcine` without `schema`, decoded as `T`, if this effect is `name` at `major`.
215    pub fn ffcine_params<T: DeserializeOwned>(&self, name: &str, major: u32) -> Option<Result<T>> {
216        if self.ffcine_schema() != Some((name, major)) {
217            return None;
218        }
219        let mut params = self.ffcine()?.clone();
220        params.shift_remove("schema");
221        Some(T::deserialize(Value::Object(params)).map_err(Error::from))
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use serde_json::json;
229
230    #[test]
231    fn foreign_and_id() {
232        let mut t = Timeline::default();
233        t.metadata.insert(
234            "Resolve_OTIO".into(),
235            json!({"Resolve OTIO Meta Version": "1.0"}),
236        );
237        assert!(t.move_to_foreign("Resolve_OTIO", "resolve"));
238        t.set_ffcine_id("a");
239        t.insert_foreign("edit_yaml", "audio", json!("nat"));
240        assert_eq!(
241            Value::Object(t.metadata.clone()),
242            json!({"ffcine": {
243                "foreign": {
244                    "resolve": {"Resolve OTIO Meta Version": "1.0"},
245                    "edit_yaml": {"audio": "nat"},
246                },
247                "id": "a",
248            }})
249        );
250        assert_eq!(t.ffcine_id(), Some("a"));
251        assert_eq!(t.foreign("edit_yaml"), Some(&json!({"audio": "nat"})));
252    }
253
254    #[test]
255    fn ffcine_effect() {
256        let params = json!({"look": "day", "gamma": 1.2});
257        let Value::Object(params) = params else {
258            unreachable!()
259        };
260        let e = GenericEffect::ffcine("ffcine.color.grade/1", params);
261        assert_eq!(e.name, "grade");
262        assert_eq!(e.effect_name, "ffcine.color.grade");
263        assert_eq!(e.ffcine_schema(), Some(("ffcine.color.grade", 1)));
264        let p: Value = e
265            .ffcine_params("ffcine.color.grade", 1)
266            .and_then(Result::ok)
267            .unwrap_or_default();
268        assert_eq!(p, json!({"look": "day", "gamma": 1.2}));
269        assert!(e.ffcine_params::<Value>("ffcine.color.grade", 2).is_none());
270    }
271}