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#[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
67impl<'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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
124#[serde(rename_all = "camelCase")]
125pub struct ShotEntry {
126 #[serde(default, alias = "clipId", alias = "cueId")]
130 pub entry_id: String,
131 pub shot_id: String,
132 #[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#[myko_macros::myko_item]
219pub struct Timeline {
220 #[serde(default)]
223 pub library_id: String,
224 #[serde(default)]
227 pub streamer_id: String,
228 pub name: String,
229 #[serde(default, alias = "version")]
232 pub revision: u32,
233 #[serde(alias = "clips", alias = "cues")]
236 pub entries: Vec<ShotEntry>,
237 pub sort_order: u32,
238}
239
240impl Timeline {
241 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 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 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}