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    pub fn remove_entry(&mut self, entry_id: &str) {
375        self.entries.retain(|entry| entry.entry_id != entry_id);
376        self.normalize_entries();
377    }
378
379    pub fn set_entry_direction(&mut self, entry_id: &str, direction: ShotDirection) {
380        if let Some(entry) = self
381            .entries
382            .iter_mut()
383            .find(|entry| entry.entry_id == entry_id)
384        {
385            entry.direction = ShotEntryMode::from_direction(direction);
386        }
387    }
388
389    pub fn capture_count(&self) -> usize {
390        self.entries
391            .iter()
392            .map(|entry| entry.directions().len())
393            .sum()
394    }
395}