Skip to main content

tellers_timeline_core/
markers.rs

1//! OTIO markers (`Marker.2`) on the stack, on tracks and on clips.
2//!
3//! A marker flags a point (or a range) of the edit with a name, a comment and
4//! a color, so users and the agent can leave notes on the timeline. It is the
5//! plain OTIO schema, so markers survive a round trip through other OTIO tools
6//! (Resolve imports them as timeline / clip markers):
7//!
8//! ```json
9//! {
10//!   "OTIO_SCHEMA": "Marker.2",
11//!   "name": "Fix color",
12//!   "comment": "Too warm compared to the previous shot",
13//!   "color": "RED",
14//!   "marked_range": { "OTIO_SCHEMA": "TimeRange.1", "start_time": {...}, "duration": {...} },
15//!   "metadata": { "tellers.ai": { "timeline_id": "a1b2c3d4e5f6", "created_by": "<user id>" } }
16//! }
17//! ```
18//!
19//! As in OTIO, `marked_range` is expressed in the time of the object holding
20//! the marker:
21//!
22//! - on the stack, in timeline time;
23//! - on a track, in that track's time (which is timeline time, tracks all start
24//!   at 0 in the stack);
25//! - on a clip, in the clip's source (media) time, the same space as its
26//!   `source_range`. The marker therefore follows its clip when the clip is
27//!   moved, and is hidden (not deleted) when the clip is trimmed past it.
28//!
29//! The `Stack` / `Timeline` methods below take and return timeline seconds and
30//! do the conversion, so callers never deal with source time. Every marker has
31//! an id at `metadata["tellers.ai"]["timeline_id"]`, like items and tracks, and
32//! the id of the user that created it at `metadata["tellers.ai"]["created_by"]`.
33//!
34//! Gaps also carry a `markers` list so OTIO input round-trips, but the API does
35//! not place markers on gaps: gaps are merged, split and dropped freely while
36//! editing, which would lose them.
37
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40
41use crate::{IdMetadataExt, Item, Seconds, Stack, TimeRange, Timeline, Track};
42
43/// Metadata key, under the `tellers.ai` namespace, holding the creator user id.
44pub const MARKER_CREATED_BY_KEY: &str = "created_by";
45
46/// Default marker color, as in OTIO.
47pub const DEFAULT_MARKER_COLOR: &str = "RED";
48
49/// The colors OTIO defines for markers (`otio.schema.MarkerColor`), which
50/// other tools (Resolve, Premiere adapters) understand. `color` is a free
51/// string, so other values round-trip, but these are the ones to offer.
52pub const MARKER_COLORS: [&str; 11] = [
53    "PINK", "RED", "ORANGE", "YELLOW", "GREEN", "CYAN", "BLUE", "PURPLE", "MAGENTA", "BLACK",
54    "WHITE",
55];
56
57fn default_marker_schema() -> String {
58    "Marker.2".to_string()
59}
60
61/// Metadata of a marker read without one: OTIO writers that do not know about
62/// our ids still get a marker that can be addressed by id.
63fn default_marker_metadata() -> serde_json::Value {
64    crate::types::ensure_tellers_ai_with_id(serde_json::Value::Null)
65}
66
67fn default_marker_color() -> String {
68    DEFAULT_MARKER_COLOR.to_string()
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
72pub struct Marker {
73    #[serde(rename = "OTIO_SCHEMA", default = "default_marker_schema")]
74    pub otio_schema: String,
75    #[serde(default)]
76    pub name: String,
77    #[serde(default)]
78    pub comment: String,
79    #[serde(default = "default_marker_color")]
80    pub color: String,
81    #[serde(default)]
82    pub marked_range: TimeRange,
83    #[serde(
84        default = "default_marker_metadata",
85        deserialize_with = "crate::types::deserialize_metadata_with_id"
86    )]
87    pub metadata: serde_json::Value,
88}
89
90impl Default for Marker {
91    fn default() -> Self {
92        Marker::new(0.0, 0.0, "", "", None, None, None)
93    }
94}
95
96impl Marker {
97    /// A marker at `start` (in the time of its owner) lasting `duration`
98    /// seconds (0 for a point marker). `color` defaults to [`DEFAULT_MARKER_COLOR`]
99    /// and `id` to a fresh one.
100    pub fn new(
101        start: Seconds,
102        duration: Seconds,
103        name: impl Into<String>,
104        comment: impl Into<String>,
105        color: Option<String>,
106        created_by: Option<String>,
107        id: Option<String>,
108    ) -> Self {
109        let mut m = Marker {
110            otio_schema: default_marker_schema(),
111            name: name.into(),
112            comment: comment.into(),
113            color: color.unwrap_or_else(default_marker_color),
114            marked_range: TimeRange::new(duration.max(0.0), start),
115            metadata: serde_json::Value::Object(serde_json::Map::new()),
116        };
117        m.set_id(Some(id.unwrap_or_else(crate::types::gen_hex_id_12)));
118        m.set_created_by(created_by);
119        m
120    }
121
122    pub fn start(&self) -> Seconds {
123        self.marked_range.get_start_time()
124    }
125
126    pub fn duration(&self) -> Seconds {
127        self.marked_range.get_duration().max(0.0)
128    }
129
130    /// The id of the user that created this marker, if recorded.
131    pub fn get_created_by(&self) -> Option<String> {
132        self.metadata
133            .get("tellers.ai")
134            .and_then(|v| v.get(MARKER_CREATED_BY_KEY))
135            .and_then(|v| v.as_str())
136            .map(|s| s.to_string())
137    }
138
139    pub fn set_created_by(&mut self, user_id: Option<String>) {
140        if self.metadata.as_object().is_none() {
141            self.metadata = serde_json::Value::Object(serde_json::Map::new());
142        }
143        let ai = self
144            .metadata
145            .as_object_mut()
146            .unwrap()
147            .entry("tellers.ai".to_string())
148            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
149        if ai.as_object().is_none() {
150            *ai = serde_json::Value::Object(serde_json::Map::new());
151        }
152        let ai = ai.as_object_mut().unwrap();
153        match user_id {
154            Some(id) => {
155                ai.insert(
156                    MARKER_CREATED_BY_KEY.to_string(),
157                    serde_json::Value::String(id),
158                );
159            }
160            None => {
161                ai.remove(MARKER_CREATED_BY_KEY);
162            }
163        }
164    }
165}
166
167impl IdMetadataExt for Marker {
168    fn get_id(&self) -> Option<String> {
169        self.metadata
170            .get("tellers.ai")
171            .and_then(|v| v.get("timeline_id"))
172            .and_then(|v| v.as_str())
173            .map(|s| s.to_string())
174    }
175    fn set_id(&mut self, id: Option<String>) {
176        crate::metadata::write_id_to_metadata(&mut self.metadata, id)
177    }
178}
179
180/// What a marker is attached to.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
182#[serde(rename_all = "snake_case")]
183pub enum MarkerOwnerKind {
184    Stack,
185    Track,
186    Clip,
187    Gap,
188}
189
190/// A marker together with where it sits in the timeline.
191#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
192pub struct MarkerInfo {
193    pub marker: Marker,
194    pub owner_kind: MarkerOwnerKind,
195    /// Id of the track or item holding the marker; `None` for the stack.
196    pub owner_id: Option<String>,
197    /// Id of the track the marker shows on: the owner track, or the track of
198    /// the owner item. `None` for stack markers.
199    pub track_id: Option<String>,
200    /// Start of the marker in timeline seconds.
201    pub timeline_start: Seconds,
202    /// Duration of the marker in seconds (0 for a point marker).
203    pub duration: Seconds,
204    /// False when the marker starts outside the visible part of its clip
205    /// (the clip was trimmed past it). Always true for stack and track markers.
206    pub visible: bool,
207}
208
209/// Changes to apply to a marker; `None` fields are left as they are.
210#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
211pub struct MarkerUpdate {
212    pub name: Option<String>,
213    pub comment: Option<String>,
214    pub color: Option<String>,
215    /// New start, in timeline seconds.
216    pub timeline_start: Option<Seconds>,
217    pub duration: Option<Seconds>,
218}
219
220/// Offset to add to a time in item source space to get timeline time.
221fn item_source_to_timeline_offset(track: &Track, index: usize) -> Seconds {
222    let item = &track.items[index];
223    track.start_time_of_item(index) - item.get_source_range().get_start_time()
224}
225
226fn item_markers(item: &Item) -> &Vec<Marker> {
227    match item {
228        Item::Clip(c) => &c.markers,
229        Item::Gap(g) => &g.markers,
230    }
231}
232
233fn item_markers_mut(item: &mut Item) -> &mut Vec<Marker> {
234    match item {
235        Item::Clip(c) => &mut c.markers,
236        Item::Gap(g) => &mut g.markers,
237    }
238}
239
240fn find_marker_index(markers: &[Marker], marker_id: &str) -> Option<usize> {
241    markers
242        .iter()
243        .position(|m| m.get_id().as_deref() == Some(marker_id))
244}
245
246/// Where a marker lives, and the offset from its owner's time to timeline time.
247enum MarkerLocation {
248    Stack(usize),
249    Track(usize, usize),
250    Item(usize, usize, usize, Seconds),
251}
252
253impl Stack {
254    /// Every marker of the timeline (stack, then each track and its items in
255    /// stack order), with timeline times.
256    pub fn get_markers(&self) -> Vec<MarkerInfo> {
257        let mut out = Vec::new();
258        for m in &self.markers {
259            out.push(MarkerInfo {
260                marker: m.clone(),
261                owner_kind: MarkerOwnerKind::Stack,
262                owner_id: None,
263                track_id: None,
264                timeline_start: m.start(),
265                duration: m.duration(),
266                visible: true,
267            });
268        }
269        for track in &self.children {
270            let track_id = track.get_id();
271            for m in &track.markers {
272                out.push(MarkerInfo {
273                    marker: m.clone(),
274                    owner_kind: MarkerOwnerKind::Track,
275                    owner_id: track_id.clone(),
276                    track_id: track_id.clone(),
277                    timeline_start: m.start(),
278                    duration: m.duration(),
279                    visible: true,
280                });
281            }
282            for (ii, item) in track.items.iter().enumerate() {
283                let markers = item_markers(item);
284                if markers.is_empty() {
285                    continue;
286                }
287                let offset = item_source_to_timeline_offset(track, ii);
288                let range = item.get_source_range();
289                let (src_start, src_end) = (
290                    range.get_start_time(),
291                    range.get_start_time() + range.get_duration(),
292                );
293                let owner_kind = match item {
294                    Item::Clip(_) => MarkerOwnerKind::Clip,
295                    Item::Gap(_) => MarkerOwnerKind::Gap,
296                };
297                for m in markers {
298                    const EPS: Seconds = 1e-9;
299                    out.push(MarkerInfo {
300                        marker: m.clone(),
301                        owner_kind,
302                        owner_id: item.get_id(),
303                        track_id: track_id.clone(),
304                        timeline_start: m.start() + offset,
305                        duration: m.duration(),
306                        visible: m.start() >= src_start - EPS && m.start() <= src_end + EPS,
307                    });
308                }
309            }
310        }
311        out
312    }
313
314    /// The marker with id `marker_id`, wherever it is attached.
315    pub fn get_marker(&self, marker_id: &str) -> Option<MarkerInfo> {
316        self.get_markers()
317            .into_iter()
318            .find(|info| info.marker.get_id().as_deref() == Some(marker_id))
319    }
320
321    fn locate_marker(&self, marker_id: &str) -> Option<MarkerLocation> {
322        if let Some(mi) = find_marker_index(&self.markers, marker_id) {
323            return Some(MarkerLocation::Stack(mi));
324        }
325        for (ti, track) in self.children.iter().enumerate() {
326            if let Some(mi) = find_marker_index(&track.markers, marker_id) {
327                return Some(MarkerLocation::Track(ti, mi));
328            }
329            for (ii, item) in track.items.iter().enumerate() {
330                if let Some(mi) = find_marker_index(item_markers(item), marker_id) {
331                    let offset = item_source_to_timeline_offset(track, ii);
332                    return Some(MarkerLocation::Item(ti, ii, mi, offset));
333                }
334            }
335        }
336        None
337    }
338
339    fn marker_mut(&mut self, location: &MarkerLocation) -> (&mut Marker, Seconds) {
340        match *location {
341            MarkerLocation::Stack(mi) => (&mut self.markers[mi], 0.0),
342            MarkerLocation::Track(ti, mi) => (&mut self.children[ti].markers[mi], 0.0),
343            MarkerLocation::Item(ti, ii, mi, offset) => (
344                &mut item_markers_mut(&mut self.children[ti].items[ii])[mi],
345                offset,
346            ),
347        }
348    }
349
350    /// Attach `marker` to the stack (`owner_id` `None`, or the stack's own id),
351    /// to the track with id `owner_id`, or to the clip with id `owner_id`.
352    ///
353    /// `marker.marked_range.start_time` is read as timeline seconds and stored
354    /// in the owner's time. A marker without an id gets a fresh one, and one
355    /// whose id is already used in the timeline is refused. Returns the
356    /// marker id, or `None` when the owner is not found (or is a gap).
357    pub fn add_marker(&mut self, owner_id: Option<&str>, mut marker: Marker) -> Option<String> {
358        let marker_id = match marker.get_id() {
359            Some(id) if !id.is_empty() => id,
360            _ => {
361                let id = crate::types::gen_hex_id_12();
362                marker.set_id(Some(id.clone()));
363                id
364            }
365        };
366        if self.locate_marker(&marker_id).is_some() {
367            return None;
368        }
369        let timeline_start = marker.start();
370        let stack_id = self.metadata.get("tellers.ai")
371            .and_then(|v| v.get("timeline_id"))
372            .and_then(|v| v.as_str())
373            .map(|s| s.to_string());
374        match owner_id {
375            None => {
376                self.markers.push(marker);
377                return Some(marker_id);
378            }
379            Some(id) if stack_id.as_deref() == Some(id) => {
380                self.markers.push(marker);
381                return Some(marker_id);
382            }
383            Some(id) => {
384                if let Some(ti) = self
385                    .children
386                    .iter()
387                    .position(|t| t.get_id().as_deref() == Some(id))
388                {
389                    self.children[ti].markers.push(marker);
390                    return Some(marker_id);
391                }
392                for track in &mut self.children {
393                    let Some(ii) = track
394                        .items
395                        .iter()
396                        .position(|it| it.get_id().as_deref() == Some(id))
397                    else {
398                        continue;
399                    };
400                    let offset = item_source_to_timeline_offset(track, ii);
401                    let Item::Clip(clip) = &mut track.items[ii] else {
402                        return None;
403                    };
404                    marker
405                        .marked_range
406                        .set_start_time(timeline_start - offset);
407                    clip.markers.push(marker);
408                    return Some(marker_id);
409                }
410                None
411            }
412        }
413    }
414
415    /// Apply `update` to the marker with id `marker_id`. Returns false when
416    /// no such marker exists.
417    pub fn update_marker(&mut self, marker_id: &str, update: MarkerUpdate) -> bool {
418        let Some(location) = self.locate_marker(marker_id) else {
419            return false;
420        };
421        let (marker, offset) = self.marker_mut(&location);
422        if let Some(name) = update.name {
423            marker.name = name;
424        }
425        if let Some(comment) = update.comment {
426            marker.comment = comment;
427        }
428        if let Some(color) = update.color {
429            marker.color = color;
430        }
431        if let Some(start) = update.timeline_start {
432            marker.marked_range.set_start_time(start - offset);
433        }
434        if let Some(duration) = update.duration {
435            marker.marked_range.set_duration(duration.max(0.0));
436        }
437        true
438    }
439
440    /// Remove the marker with id `marker_id` and return it (with its range
441    /// still in its owner's time), or `None` when no such marker exists.
442    pub fn delete_marker(&mut self, marker_id: &str) -> Option<Marker> {
443        Some(match self.locate_marker(marker_id)? {
444            MarkerLocation::Stack(mi) => self.markers.remove(mi),
445            MarkerLocation::Track(ti, mi) => self.children[ti].markers.remove(mi),
446            MarkerLocation::Item(ti, ii, mi, _) => {
447                item_markers_mut(&mut self.children[ti].items[ii]).remove(mi)
448            }
449        })
450    }
451}
452
453impl Timeline {
454    /// See [`Stack::get_markers`].
455    pub fn get_markers(&self) -> Vec<MarkerInfo> {
456        self.tracks.get_markers()
457    }
458    /// See [`Stack::get_marker`].
459    pub fn get_marker(&self, marker_id: &str) -> Option<MarkerInfo> {
460        self.tracks.get_marker(marker_id)
461    }
462    /// See [`Stack::add_marker`].
463    pub fn add_marker(&mut self, owner_id: Option<&str>, marker: Marker) -> Option<String> {
464        self.tracks.add_marker(owner_id, marker)
465    }
466    /// See [`Stack::update_marker`].
467    pub fn update_marker(&mut self, marker_id: &str, update: MarkerUpdate) -> bool {
468        self.tracks.update_marker(marker_id, update)
469    }
470    /// See [`Stack::delete_marker`].
471    pub fn delete_marker(&mut self, marker_id: &str) -> Option<Marker> {
472        self.tracks.delete_marker(marker_id)
473    }
474}