Skip to main content

tellers_timeline_core/
associated_tracks.rs

1//! Associated tracks: where a track's sync partners go.
2//!
3//! A clip inserted with sync partners (a video with its audio channels, or an
4//! audio clip with other audio clips synced to it) lands as a column across
5//! several tracks. The track receiving the primary clip is the *primary track*;
6//! the tracks receiving its partners are its *associated tracks*.
7//!
8//! Nothing is stored for this: the association is computed from the sync
9//! clips (the Resolve "Link Group ID" on clips) every time it is needed, so
10//! the saved document is plain OTIO and never drifts from the clips.
11//!
12//! Every sync column has an owner track: its video track, or, for an
13//! audio-only column, the member track holding the most clips (highest index
14//! on a tie). The owner's associated tracks are the tracks holding the other
15//! clips of its columns, nearest to the owner first. A track belongs to one
16//! owner: when two owners share a track it goes to the one it shares the
17//! most columns with. A track holding no sync clip belongs to nobody: it is
18//! *free*, and any column may adopt it.
19//!
20//! Slot order follows: the first partner of a column goes to the nearest
21//! associated track that is free over the column, the second to the next,
22//! and so on; when there is no room a new track is created past the existing
23//! partners, so after [`Stack::normalize_track_order`] the slot order is also
24//! the stack order below the owner. Associated tracks are reserved for their
25//! owner's columns; [`Stack::free_audio_track_for`] picks a track for a plain
26//! audio clip accordingly.
27//!
28//! The tracks the library creates (numbered `A3`, `V2`, …) are marked at
29//! `metadata["tellers.ai"]["created_by"] = "auto"`, so
30//! [`Stack::prune_empty_partner_tracks`] can remove the audio ones once empty without
31//! guessing from their name. Renaming a track with [`Track::set_name`] clears
32//! the mark: a track the user named is theirs to keep.
33
34use std::collections::BTreeMap;
35
36use crate::{IdMetadataExt, Item, Seconds, Stack, Track, TrackKind};
37
38/// The key of a track's creator at `metadata["tellers.ai"]`.
39pub const TRACK_CREATED_BY_KEY: &str = "created_by";
40/// The creator value of the tracks the library creates itself.
41pub const TRACK_CREATED_BY_AUTO: &str = "auto";
42
43impl Track {
44    /// Whether the library created this track (see [`Self::set_auto_created`]).
45    pub fn is_auto_created(&self) -> bool {
46        self.metadata
47            .get("tellers.ai")
48            .and_then(|ai| ai.get(TRACK_CREATED_BY_KEY))
49            .and_then(|value| value.as_str())
50            == Some(TRACK_CREATED_BY_AUTO)
51    }
52
53    /// Mark (or unmark) this track as created by the library, which makes it
54    /// a candidate for [`Stack::prune_empty_partner_tracks`].
55    pub fn set_auto_created(&mut self, auto_created: bool) {
56        if self.metadata.as_object().is_none() {
57            self.metadata = serde_json::Value::Object(serde_json::Map::new());
58        }
59        let map = self.metadata.as_object_mut().unwrap();
60        if auto_created {
61            let ai = map
62                .entry("tellers.ai".to_string())
63                .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
64            if ai.as_object().is_none() {
65                *ai = serde_json::Value::Object(serde_json::Map::new());
66            }
67            ai.as_object_mut().unwrap().insert(
68                TRACK_CREATED_BY_KEY.to_string(),
69                serde_json::Value::String(TRACK_CREATED_BY_AUTO.to_string()),
70            );
71        } else if let Some(ai) = map.get_mut("tellers.ai").and_then(|ai| ai.as_object_mut()) {
72            ai.remove(TRACK_CREATED_BY_KEY);
73        }
74    }
75
76    /// Rename the track. A rename makes the track the user's: it is no longer
77    /// marked as created by the library, so pruning leaves it alone.
78    pub fn set_name(&mut self, name: Option<String>) {
79        self.name = name;
80        self.set_auto_created(false);
81    }
82}
83
84/// One track group: an owner with its partners in slot order. A track in no
85/// group is reported as a group of one. Hosts use this to draw or collapse a
86/// group as one unit.
87#[derive(Debug, Clone, PartialEq)]
88pub struct TrackGroup {
89    /// Index of the owner track (the video track of a video group, the main
90    /// track of an audio group).
91    pub owner_track_index: usize,
92    /// Id of the owner track, when it has one.
93    pub owner_track_id: Option<String>,
94    /// Kind of the owner: `Video` for a video group, `Audio` for an audio group.
95    pub kind: TrackKind,
96    /// Partner track indices in slot order (first partner right below the
97    /// owner once normalized).
98    pub partner_track_indices: Vec<usize>,
99    /// Partner track ids in slot order.
100    pub partner_track_ids: Vec<Option<String>>,
101}
102
103impl Stack {
104    /// Sync id -> member track indices, groups in order of first appearance.
105    fn sync_group_members(&self) -> BTreeMap<i64, Vec<usize>> {
106        let mut members: BTreeMap<i64, Vec<usize>> = BTreeMap::new();
107        for (track_index, track) in self.children.iter().enumerate() {
108            for item in &track.items {
109                let Item::Clip(clip) = item else {
110                    continue;
111                };
112                let Some(sync_clips_id) = clip.sync_clips_id() else {
113                    continue;
114                };
115                let tracks = members.entry(sync_clips_id).or_default();
116                if !tracks.contains(&track_index) {
117                    tracks.push(track_index);
118                }
119            }
120        }
121        members
122    }
123
124    /// Number of clips (not gaps) on a track.
125    fn track_clip_count(&self, track_index: usize) -> usize {
126        self.children
127            .get(track_index)
128            .map(|track| {
129                track
130                    .items
131                    .iter()
132                    .filter(|item| matches!(item, Item::Clip(_)))
133                    .count()
134            })
135            .unwrap_or(0)
136    }
137
138    /// The main track of an audio group: the member holding the most clips,
139    /// the highest index on a tie. It owns the group and sits on top of it
140    /// once normalized.
141    pub fn audio_group_main_track(&self, member_track_indices: &[usize]) -> Option<usize> {
142        member_track_indices
143            .iter()
144            .copied()
145            .max_by_key(|&index| (self.track_clip_count(index), index))
146    }
147
148    /// The owner of a sync column spread over `member_track_indices`: its
149    /// highest video track, else the audio group's main track.
150    fn sync_group_owner(&self, member_track_indices: &[usize]) -> Option<usize> {
151        member_track_indices
152            .iter()
153            .copied()
154            .filter(|&index| self.children[index].kind == TrackKind::Video)
155            .max()
156            .or_else(|| self.audio_group_main_track(member_track_indices))
157    }
158
159    /// The association for every track at once: `map[owner]` lists the
160    /// owner's partner tracks nearest first. Computed from the sync clips, see
161    /// the module docs for the owner and exclusivity rules.
162    pub fn association_map(&self) -> Vec<Vec<usize>> {
163        let len = self.children.len();
164        // (partner, owner) -> how many columns they share.
165        let mut shared: Vec<Vec<(usize, usize)>> = vec![Vec::new(); len];
166        for members in self.sync_group_members().values() {
167            if members.len() < 2 {
168                continue;
169            }
170            let Some(owner) = self.sync_group_owner(members) else {
171                continue;
172            };
173            for &partner in members.iter().filter(|&&index| index != owner) {
174                match shared[owner]
175                    .iter_mut()
176                    .find(|(index, _)| *index == partner)
177                {
178                    Some((_, count)) => *count += 1,
179                    None => shared[owner].push((partner, 1)),
180                }
181            }
182        }
183        // Exclusive membership: the owner sharing the most columns wins,
184        // the lower index on a tie.
185        let mut best_owner: Vec<Option<(usize, usize)>> = vec![None; len];
186        for (owner, list) in shared.iter().enumerate() {
187            for &(partner, count) in list {
188                if best_owner[partner].is_none_or(|(_, best)| count > best) {
189                    best_owner[partner] = Some((owner, count));
190                }
191            }
192        }
193        shared
194            .into_iter()
195            .enumerate()
196            .map(|(owner, list)| {
197                let mut partners: Vec<usize> = list
198                    .into_iter()
199                    .filter(|&(partner, _)| best_owner[partner].map(|(o, _)| o) == Some(owner))
200                    .map(|(partner, _)| partner)
201                    .collect();
202                partners.sort_by_key(|&index| (index.abs_diff(owner), index));
203                partners
204            })
205            .collect()
206    }
207
208    /// The associated tracks of `track_index`, nearest first.
209    pub fn associated_track_indices(&self, track_index: usize) -> Vec<usize> {
210        self.association_map()
211            .into_iter()
212            .nth(track_index)
213            .unwrap_or_default()
214    }
215
216    /// The associated track ids of the track with `track_id`, nearest first.
217    pub fn associated_track_ids(&self, track_id: &str) -> Option<Vec<String>> {
218        let (track_index, _) = self.get_track_by_id(track_id)?;
219        Some(
220            self.associated_track_indices(track_index)
221                .into_iter()
222                .filter_map(|index| self.children[index].get_id())
223                .collect(),
224        )
225    }
226
227    /// Indices of the tracks whose association contains `track_index`.
228    pub fn tracks_associating(&self, track_index: usize) -> Vec<usize> {
229        self.association_map()
230            .iter()
231            .enumerate()
232            .filter(|(owner, partners)| *owner != track_index && partners.contains(&track_index))
233            .map(|(owner, _)| owner)
234            .collect()
235    }
236
237    /// Whether some other track has `track_index` as an associated track.
238    pub fn is_associated_partner(&self, track_index: usize) -> bool {
239        !self.tracks_associating(track_index).is_empty()
240    }
241
242    /// The owner of the group `track_index` belongs to: the track it is a
243    /// partner of, or itself (an owner, or a track in no group).
244    pub fn group_owner_of(&self, track_index: usize) -> usize {
245        self.tracks_associating(track_index)
246            .into_iter()
247            .next()
248            .unwrap_or(track_index)
249    }
250
251    /// Whether `track_index` is in no group at all: neither a partner nor an
252    /// owner. Such a track can be adopted by any column.
253    pub fn track_is_free(&self, track_index: usize) -> bool {
254        !self.is_associated_partner(track_index)
255            && self.associated_track_indices(track_index).is_empty()
256    }
257
258    /// Whether `track_index` is the owner or a partner of a video group.
259    pub fn track_in_video_group(&self, track_index: usize) -> bool {
260        self.children
261            .get(self.group_owner_of(track_index))
262            .is_some_and(|track| track.kind == TrackKind::Video)
263    }
264
265    /// Whether a column may land with its primary clip on `dest_track_index`.
266    ///
267    /// On a video track anything but a column that already has a video clip.
268    /// On an audio track: a gap always; the audio partner of a video column on
269    /// a track of a video group (its own, for a swap, or another one, moving
270    /// the whole column) or on a free track (adopted), never on an audio
271    /// group; a plain audio clip or an audio-only column never on a video
272    /// group's track. A track holding no sync clip is free, so an empty
273    /// track never blocks anything.
274    pub(crate) fn column_may_land_on(
275        &self,
276        dest_track_index: usize,
277        primary_is_gap: bool,
278        has_video_partner: bool,
279    ) -> bool {
280        let Some(dest) = self.children.get(dest_track_index) else {
281            return false;
282        };
283        match dest.kind {
284            TrackKind::Video => !has_video_partner,
285            TrackKind::Audio => {
286                if primary_is_gap {
287                    return true;
288                }
289                let in_video_group = self.track_in_video_group(dest_track_index);
290                if has_video_partner {
291                    in_video_group || self.track_is_free(dest_track_index)
292                } else {
293                    !in_video_group
294                }
295            }
296            TrackKind::Other => !has_video_partner,
297        }
298    }
299
300    /// Every track group, owners in stack order (bottom to top). Tracks in no
301    /// group come back as groups of one, so every track appears exactly once.
302    pub fn track_groups(&self) -> Vec<TrackGroup> {
303        let map = self.association_map();
304        let len = self.children.len();
305        let mut owner_of: Vec<usize> = (0..len).collect();
306        for (owner, partners) in map.iter().enumerate() {
307            for &partner in partners {
308                owner_of[partner] = owner;
309            }
310        }
311        (0..len)
312            .filter(|&owner| owner_of[owner] == owner)
313            .map(|owner| {
314                let partner_track_indices: Vec<usize> = map[owner]
315                    .iter()
316                    .copied()
317                    .filter(|&partner| owner_of[partner] == owner)
318                    .collect();
319                TrackGroup {
320                    owner_track_index: owner,
321                    owner_track_id: self.children[owner].get_id(),
322                    kind: self.children[owner].kind.clone(),
323                    partner_track_ids: partner_track_indices
324                        .iter()
325                        .map(|&partner| self.children[partner].get_id())
326                        .collect(),
327                    partner_track_indices,
328                }
329            })
330            .collect()
331    }
332
333    /// The track that owns a column spread over `member_track_indices`: the
334    /// member with the most of the others as associated tracks; ties go to a
335    /// video track, then to the highest index. Partner slots of the column
336    /// are resolved from this track whichever member is moved.
337    pub(crate) fn column_owner_track_index(&self, member_track_indices: &[usize]) -> Option<usize> {
338        let map = self.association_map();
339        member_track_indices
340            .iter()
341            .copied()
342            .filter(|&index| index < self.children.len())
343            .max_by_key(|&index| {
344                let listed = map[index]
345                    .iter()
346                    .filter(|partner| member_track_indices.contains(partner))
347                    .count();
348                (listed, self.children[index].kind == TrackKind::Video, index)
349            })
350    }
351
352    /// Remove the audio tracks the library created that hold no clip any
353    /// more: tracks marked as auto-created (see [`Track::is_auto_created`])
354    /// with gaps only, or nothing. Tracks the host created or renamed stay, as
355    /// do all tracks holding a clip. Returns the removed track ids.
356    ///
357    /// A separate step, like [`Self::normalize_track_order`]: the host runs it
358    /// when it wants the clutter gone, e.g. on save.
359    pub fn prune_empty_partner_tracks(&mut self) -> Vec<String> {
360        let candidates: Vec<String> = self
361            .children
362            .iter()
363            .filter(|track| {
364                track.kind == TrackKind::Audio
365                    && track.items.iter().all(|item| matches!(item, Item::Gap(_)))
366                    && track.is_auto_created()
367            })
368            .filter_map(|track| track.get_id())
369            .collect();
370        let mut removed = Vec::new();
371        for id in candidates {
372            if let Some((index, _)) = self.get_track_by_id(&id) {
373                self.children.remove(index);
374                removed.push(id);
375            }
376        }
377        removed
378    }
379
380    /// Reorder the tracks so every group is contiguous: the owner on top
381    /// (highest index of the group) with its partners right below it in slot
382    /// order. Groups keep the relative order of their owners; tracks in no
383    /// group keep their place among them. Returns whether the order changed.
384    ///
385    /// This is a separate step the host runs once per request, after its
386    /// edits: every index it holds across library calls stays valid until then.
387    pub fn normalize_track_order(&mut self) -> bool {
388        let len = self.children.len();
389        let mut order: Vec<usize> = Vec::with_capacity(len);
390        for group in self.track_groups() {
391            order.extend(group.partner_track_indices.iter().rev());
392            order.push(group.owner_track_index);
393        }
394        for index in 0..len {
395            if !order.contains(&index) {
396                order.push(index);
397            }
398        }
399        if order
400            .iter()
401            .enumerate()
402            .all(|(position, &index)| position == index)
403        {
404            return false;
405        }
406        let mut tracks: Vec<Option<Track>> = self.children.drain(..).map(Some).collect();
407        self.children = order
408            .into_iter()
409            .map(|index| tracks[index].take().expect("each track placed once"))
410            .collect();
411        true
412    }
413
414    /// Pick an existing audio track for a clip that has no sync partners,
415    /// free over `[start, start + duration]`, never a video group's audio
416    /// track (a plain clip is refused there). Tracks in no group come first,
417    /// then audio-group tracks, lowest index first. `None` when no audio
418    /// track is free there; the caller then adds a track.
419    pub fn free_audio_track_for(&self, start: Seconds, duration: Seconds) -> Option<String> {
420        let end = start + duration.max(0.0);
421        let mut candidates: Vec<usize> = self
422            .children
423            .iter()
424            .enumerate()
425            .filter(|(index, track)| {
426                track.kind == TrackKind::Audio
427                    && Stack::track_range_is_free(track, start, end)
428                    && self.children[*index].get_id().is_some()
429                    && !self.track_in_video_group(*index)
430            })
431            .map(|(index, _)| index)
432            .collect();
433        candidates.sort_by_key(|&index| (!self.track_is_free(index), index));
434        candidates
435            .into_iter()
436            .next()
437            .and_then(|index| self.children[index].get_id())
438    }
439}