Skip to main content

pulse_pixelstream_types/
timeline.rs

1use myko::prelude::*;
2use std::fmt;
3
4use myko::TS;
5use serde::{de, Deserialize, Deserializer, Serialize};
6
7use crate::{shot::effective_library_id, shot::DEFAULT_SHOT_LIBRARY_ID, Shot};
8
9#[derive(
10    Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, TS, PartialOrd, Ord,
11)]
12#[serde(rename_all = "snake_case")]
13pub enum ShotDirection {
14    #[default]
15    Forward,
16    Reverse,
17}
18
19impl ShotDirection {
20    pub fn label(self) -> &'static str {
21        match self {
22            Self::Forward => "Forward",
23            Self::Reverse => "Reverse",
24        }
25    }
26}
27
28/// Legacy wire policy for directional capture membership. Newly-authored shot
29/// entries use one direction each; `Both` is accepted only to migrate older
30/// rows into two independent entries.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, TS, PartialOrd, Ord)]
32#[serde(rename_all = "snake_case")]
33pub enum ShotEntryMode {
34    #[default]
35    Forward,
36    Reverse,
37    Both,
38    Excluded,
39}
40
41impl ShotEntryMode {
42    pub fn directions(self) -> &'static [ShotDirection] {
43        match self {
44            Self::Forward => &[ShotDirection::Forward],
45            Self::Reverse => &[ShotDirection::Reverse],
46            Self::Both => &[ShotDirection::Forward, ShotDirection::Reverse],
47            Self::Excluded => &[],
48        }
49    }
50
51    pub fn from_direction(direction: ShotDirection) -> Self {
52        match direction {
53            ShotDirection::Forward => Self::Forward,
54            ShotDirection::Reverse => Self::Reverse,
55        }
56    }
57
58    pub fn single_direction(self) -> Option<ShotDirection> {
59        match self {
60            Self::Forward => Some(ShotDirection::Forward),
61            Self::Reverse => Some(ShotDirection::Reverse),
62            Self::Both | Self::Excluded => None,
63        }
64    }
65}
66
67/// Deserialize both the string policy and the legacy boolean field. With
68/// `alias = "enabled"` on a containing field, persisted values migrate without
69/// a separate data rewrite.
70impl<'de> Deserialize<'de> for ShotEntryMode {
71    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
72    where
73        D: Deserializer<'de>,
74    {
75        struct Visitor;
76
77        impl<'de> de::Visitor<'de> for Visitor {
78            type Value = ShotEntryMode;
79
80            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81                formatter.write_str("forward, reverse, both, excluded, or a legacy boolean")
82            }
83
84            fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
85            where
86                E: de::Error,
87            {
88                Ok(if value {
89                    ShotEntryMode::Forward
90                } else {
91                    ShotEntryMode::Excluded
92                })
93            }
94
95            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
96            where
97                E: de::Error,
98            {
99                match value {
100                    "forward" => Ok(ShotEntryMode::Forward),
101                    "reverse" => Ok(ShotEntryMode::Reverse),
102                    "both" => Ok(ShotEntryMode::Both),
103                    "excluded" | "none" => Ok(ShotEntryMode::Excluded),
104                    other => Err(E::unknown_variant(
105                        other,
106                        &["forward", "reverse", "both", "excluded"],
107                    )),
108                }
109            }
110        }
111
112        deserializer.deserialize_any(Visitor)
113    }
114}
115
116/// One persistent appearance of a Shot in a reusable timeline.
117///
118/// The referenced Shot supplies identity and library defaults. Optional entry
119/// values are per-Timeline overrides, allowing the same camera target to execute at a
120/// different speed or duration in different timelines. Missing values exist
121/// only for wire compatibility and are materialized server-side on discovery,
122/// import, save, or capture start.
123#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
124#[serde(rename_all = "camelCase")]
125pub struct ShotEntry {
126    /// Stable identity for this particular appearance in a timeline. Multiple
127    /// clips may reference the same Shot, just as multiple OTIO Clips may
128    /// reference the same media/source object.
129    #[serde(default, alias = "clipId", alias = "cueId")]
130    pub entry_id: String,
131    pub shot_id: String,
132    /// Playback/capture direction for this ShotEntry.
133    #[serde(alias = "mode")]
134    pub direction: ShotEntryMode,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub shot_index: Option<u32>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub translation_speed_cm_s: Option<f32>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub rotation_speed_deg_s: Option<f32>,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub hold_duration_ms: Option<u64>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub travel_duration_ms: Option<u64>,
145}
146
147impl ShotEntry {
148    pub fn directions(&self) -> &'static [ShotDirection] {
149        self.direction.directions()
150    }
151
152    pub fn capture_key(&self, direction: ShotDirection) -> String {
153        let direction_name = direction.label().to_ascii_lowercase();
154        if self.entry_id.trim().is_empty() {
155            format!("{}:{direction_name}", self.shot_id)
156        } else if self.direction == ShotEntryMode::Both {
157            format!("{}:{direction_name}", self.entry_id)
158        } else {
159            self.entry_id.clone()
160        }
161    }
162
163    pub fn from_shot(shot: &Shot, direction: ShotEntryMode) -> Self {
164        Self {
165            entry_id: String::new(),
166            shot_id: shot.id.to_string(),
167            direction,
168            shot_index: Some(shot.shot_index),
169            translation_speed_cm_s: Some(shot.translation_speed_cm_s),
170            rotation_speed_deg_s: Some(shot.rotation_speed_deg_s),
171            hold_duration_ms: Some(shot.hold_duration_ms),
172            travel_duration_ms: Some(shot.effective_travel_duration_ms()),
173        }
174    }
175
176    pub fn from_shot_with_id(
177        shot: &Shot,
178        direction: ShotDirection,
179        entry_id: impl Into<String>,
180    ) -> Self {
181        let mut entry = Self::from_shot(shot, ShotEntryMode::from_direction(direction));
182        entry.entry_id = entry_id.into();
183        entry
184    }
185
186    pub fn resolved_shot(&self, shot: &Shot) -> Shot {
187        let mut resolved = shot.clone();
188        resolved.shot_index = self.shot_index.unwrap_or(shot.shot_index);
189        resolved.translation_speed_cm_s = self
190            .translation_speed_cm_s
191            .unwrap_or(shot.translation_speed_cm_s);
192        resolved.rotation_speed_deg_s = self
193            .rotation_speed_deg_s
194            .unwrap_or(shot.rotation_speed_deg_s);
195        resolved.hold_duration_ms = self.hold_duration_ms.unwrap_or(shot.hold_duration_ms);
196        resolved.travel_duration_ms = self
197            .travel_duration_ms
198            .filter(|duration| *duration > 0)
199            .unwrap_or_else(|| shot.effective_travel_duration_ms());
200        resolved
201    }
202
203    pub fn set_parameters_from_shot(&mut self, shot: &Shot) {
204        self.shot_index = Some(shot.shot_index);
205        self.translation_speed_cm_s = Some(shot.translation_speed_cm_s);
206        self.rotation_speed_deg_s = Some(shot.rotation_speed_deg_s);
207        self.hold_duration_ms = Some(shot.hold_duration_ms);
208        self.travel_duration_ms = Some(shot.effective_travel_duration_ms());
209    }
210}
211
212/// A named, reusable timeline in a shared definition library.
213///
214/// Like an OTIO Track, `entries` is an ordered sequence of shot appearances.
215/// A Shot is the reusable source definition; any number of entries may reference
216/// it, and every entry owns its direction and capture parameters independently.
217/// `shot_index` is an authored production label, not a competing sort key.
218#[myko_macros::myko_item]
219pub struct Timeline {
220    /// Shared definition scope. Empty on legacy rows and interpreted as the
221    /// default shared library.
222    #[serde(default)]
223    pub library_id: String,
224    /// Legacy provenance retained for wire and persisted-row compatibility.
225    /// The active stream is supplied only when a RecordingJob starts.
226    #[serde(default)]
227    pub streamer_id: String,
228    pub name: String,
229    /// Immutable-program revision. Editing the timeline advances this value;
230    /// recording it does not. Legacy rows stored this as `version`.
231    #[serde(default, alias = "version")]
232    pub revision: u32,
233    /// Ordered shot appearances. They project to OTIO Clips only at the
234    /// interchange boundary. Legacy persistence rows used `clips` or `cues`.
235    #[serde(alias = "clips", alias = "cues")]
236    pub entries: Vec<ShotEntry>,
237    pub sort_order: u32,
238}
239
240impl Timeline {
241    /// Historic identity used by the retired automatic shot-list workflow.
242    /// Kept only so persisted rows and their references can be reconciled.
243    pub fn legacy_default_id(library_id: &str) -> String {
244        format!("{library_id}:timeline:default")
245    }
246
247    pub fn shared_legacy_default_id() -> String {
248        Self::legacy_default_id(DEFAULT_SHOT_LIBRARY_ID)
249    }
250
251    pub fn has_legacy_default_identity(&self) -> bool {
252        let id = self.id.as_ref();
253        id.ends_with(":timeline:default") || id.ends_with(":shot-list:default")
254    }
255
256    pub fn has_legacy_default_name(&self) -> bool {
257        matches!(
258            self.name.trim().to_ascii_lowercase().as_str(),
259            "default timeline" | "default shot list"
260        )
261    }
262
263    pub fn effective_library_id(&self) -> &str {
264        effective_library_id(&self.library_id)
265    }
266
267    pub fn next_revision(&self) -> u32 {
268        self.revision.saturating_add(1).max(1)
269    }
270
271    /// Normalize legacy memberships into ordered ShotEntry instances. A legacy
272    /// `Both` membership becomes two independently addressable entries, while
273    /// repeated references to the same Shot are deliberately preserved.
274    pub fn normalize_entries(&mut self) {
275        let timeline_id = self.id.to_string();
276        let mut seen_ids = std::collections::HashSet::new();
277        let mut normalized = Vec::new();
278        for (position, entry) in std::mem::take(&mut self.entries).into_iter().enumerate() {
279            if entry.shot_id.trim().is_empty() || entry.direction == ShotEntryMode::Excluded {
280                continue;
281            }
282            let directions = entry.direction.directions();
283            for direction in directions {
284                let mut instance = entry.clone();
285                instance.direction = ShotEntryMode::from_direction(*direction);
286                let direction_name = direction.label().to_ascii_lowercase();
287                let base_id = if entry.entry_id.trim().is_empty() {
288                    format!("{timeline_id}:entry:{position}")
289                } else {
290                    entry.entry_id.trim().to_owned()
291                };
292                let candidate = if directions.len() > 1 {
293                    format!("{base_id}:{direction_name}")
294                } else {
295                    base_id
296                };
297                let mut entry_id = candidate.clone();
298                let mut collision = 2_u32;
299                while !seen_ids.insert(entry_id.clone()) {
300                    entry_id = format!("{candidate}:{collision}");
301                    collision = collision.saturating_add(1);
302                }
303                instance.entry_id = entry_id;
304                normalized.push(instance);
305            }
306        }
307        self.entries = normalized;
308    }
309
310    /// Materialize every legacy optional entry value from its referenced Shot.
311    /// Once this returns true, later Shot-default edits cannot leak across timelines.
312    pub fn backfill_entry_parameters(&mut self, shots: &[Shot]) -> bool {
313        let by_id = shots
314            .iter()
315            .map(|shot| (shot.id.to_string(), shot))
316            .collect::<std::collections::HashMap<_, _>>();
317        let mut changed = false;
318        for entry in &mut self.entries {
319            let Some(shot) = by_id.get(&entry.shot_id) else {
320                continue;
321            };
322            if entry.shot_index.is_none()
323                || entry.translation_speed_cm_s.is_none()
324                || entry.rotation_speed_deg_s.is_none()
325                || entry.hold_duration_ms.is_none()
326                || entry
327                    .travel_duration_ms
328                    .is_none_or(|duration| duration == 0)
329            {
330                let resolved = entry.resolved_shot(shot);
331                entry.set_parameters_from_shot(&resolved);
332                changed = true;
333            }
334        }
335        changed
336    }
337
338    pub fn add_entry(&mut self, shot_id: &str, direction: ShotEntryMode) {
339        if direction == ShotEntryMode::Excluded {
340            return;
341        }
342        self.entries.push(ShotEntry {
343            entry_id: String::new(),
344            shot_id: shot_id.to_owned(),
345            direction,
346            shot_index: None,
347            translation_speed_cm_s: None,
348            rotation_speed_deg_s: None,
349            hold_duration_ms: None,
350            travel_duration_ms: None,
351        });
352        self.normalize_entries();
353    }
354
355    pub fn add_shot_entry(&mut self, shot: &Shot, direction: ShotEntryMode) {
356        if direction == ShotEntryMode::Excluded {
357            return;
358        }
359        self.entries.push(ShotEntry::from_shot(shot, direction));
360        self.normalize_entries();
361    }
362
363    pub fn add_shot_entry_instance(
364        &mut self,
365        shot: &Shot,
366        direction: ShotDirection,
367        entry_id: impl Into<String>,
368    ) {
369        self.entries
370            .push(ShotEntry::from_shot_with_id(shot, direction, entry_id));
371        self.normalize_entries();
372    }
373
374    /// Move an entry to `position`, shifting the rest to keep the sequence
375    /// contiguous. Out-of-range positions clamp to the ends.
376    ///
377    /// This is the whole ordering model: `entries` order IS the program order,
378    /// and the number an operator sees is that position. Nothing sorts by
379    /// `shot_index` — it travels with the entry as an authored label.
380    pub fn move_entry(&mut self, entry_id: &str, position: usize) {
381        let Some(current) = self
382            .entries
383            .iter()
384            .position(|entry| entry.entry_id == entry_id)
385        else {
386            return;
387        };
388        let entry = self.entries.remove(current);
389        let target = position.min(self.entries.len());
390        self.entries.insert(target, entry);
391    }
392
393    /// Insert an entry at `position`, or append when it is `None` or past the
394    /// end.
395    pub fn insert_shot_entry(
396        &mut self,
397        shot: &Shot,
398        direction: ShotDirection,
399        entry_id: impl Into<String>,
400        position: Option<usize>,
401    ) {
402        let entry = ShotEntry::from_shot_with_id(shot, direction, entry_id);
403        match position {
404            Some(index) if index < self.entries.len() => self.entries.insert(index, entry),
405            _ => self.entries.push(entry),
406        }
407        self.normalize_entries();
408    }
409
410    /// Position of an entry in the program, if present.
411    pub fn entry_position(&self, entry_id: &str) -> Option<usize> {
412        self.entries
413            .iter()
414            .position(|entry| entry.entry_id == entry_id)
415    }
416
417    pub fn remove_entry(&mut self, entry_id: &str) {
418        self.entries.retain(|entry| entry.entry_id != entry_id);
419        self.normalize_entries();
420    }
421
422    pub fn set_entry_direction(&mut self, entry_id: &str, direction: ShotDirection) {
423        if let Some(entry) = self
424            .entries
425            .iter_mut()
426            .find(|entry| entry.entry_id == entry_id)
427        {
428            entry.direction = ShotEntryMode::from_direction(direction);
429        }
430    }
431
432    pub fn capture_count(&self) -> usize {
433        self.entries
434            .iter()
435            .map(|entry| entry.directions().len())
436            .sum()
437    }
438}
439
440#[cfg(test)]
441mod ordering_tests {
442    use super::*;
443
444    fn shot(id: &str) -> Shot {
445        Shot {
446            id: id.into(),
447            library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
448            streamer_id: String::new(),
449            name: id.to_owned(),
450            kind: crate::ShotKind::Static,
451            target_name: id.to_owned(),
452            translation_speed_cm_s: 10.0,
453            rotation_speed_deg_s: 2.0,
454            hold_duration_ms: 5_000,
455            travel_duration_ms: 30_000,
456            default_entry_mode: ShotEntryMode::Forward,
457            shot_index: 0,
458        }
459    }
460
461    fn timeline_of(ids: &[&str]) -> Timeline {
462        let mut timeline = Timeline {
463            id: "t1".into(),
464            library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
465            streamer_id: String::new(),
466            name: "Program".to_owned(),
467            revision: 1,
468            entries: Vec::new(),
469            sort_order: 0,
470        };
471        for id in ids {
472            timeline.insert_shot_entry(&shot(id), ShotDirection::Forward, *id, None);
473        }
474        timeline
475    }
476
477    fn order(timeline: &Timeline) -> Vec<String> {
478        timeline
479            .entries
480            .iter()
481            .map(|entry| entry.entry_id.clone())
482            .collect()
483    }
484
485    #[test]
486    fn moving_an_entry_backwards_shifts_the_rest_down() {
487        let mut timeline = timeline_of(&["a", "b", "c", "d"]);
488        timeline.move_entry("d", 1);
489        assert_eq!(order(&timeline), ["a", "d", "b", "c"]);
490    }
491
492    #[test]
493    fn moving_an_entry_forwards_shifts_the_rest_up() {
494        let mut timeline = timeline_of(&["a", "b", "c", "d"]);
495        timeline.move_entry("a", 2);
496        assert_eq!(order(&timeline), ["b", "c", "a", "d"]);
497    }
498
499    #[test]
500    fn a_position_past_the_end_lands_last_rather_than_failing() {
501        let mut timeline = timeline_of(&["a", "b", "c"]);
502        timeline.move_entry("a", 99);
503        assert_eq!(order(&timeline), ["b", "c", "a"]);
504    }
505
506    #[test]
507    fn moving_an_absent_entry_changes_nothing() {
508        let mut timeline = timeline_of(&["a", "b"]);
509        timeline.move_entry("nope", 0);
510        assert_eq!(order(&timeline), ["a", "b"]);
511    }
512
513    #[test]
514    fn inserting_at_a_position_puts_the_shot_there() {
515        let mut timeline = timeline_of(&["a", "b"]);
516        timeline.insert_shot_entry(&shot("c"), ShotDirection::Forward, "c", Some(1));
517        assert_eq!(order(&timeline), ["a", "c", "b"]);
518    }
519
520    #[test]
521    fn the_same_shot_can_appear_more_than_once_in_a_program() {
522        let mut timeline = timeline_of(&["a"]);
523        timeline.insert_shot_entry(&shot("a"), ShotDirection::Reverse, "a-again", None);
524        assert_eq!(order(&timeline), ["a", "a-again"]);
525        assert_eq!(timeline.entries.len(), 2);
526    }
527}