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}