use myko::prelude::*;
use std::fmt;
use myko::TS;
use serde::{de, Deserialize, Deserializer, Serialize};
use crate::{shot::effective_library_id, shot::DEFAULT_SHOT_LIBRARY_ID, Shot};
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, TS, PartialOrd, Ord,
)]
#[serde(rename_all = "snake_case")]
pub enum ShotDirection {
#[default]
Forward,
Reverse,
}
impl ShotDirection {
pub fn label(self) -> &'static str {
match self {
Self::Forward => "Forward",
Self::Reverse => "Reverse",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, TS, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ShotEntryMode {
#[default]
Forward,
Reverse,
Both,
Excluded,
}
impl ShotEntryMode {
pub fn directions(self) -> &'static [ShotDirection] {
match self {
Self::Forward => &[ShotDirection::Forward],
Self::Reverse => &[ShotDirection::Reverse],
Self::Both => &[ShotDirection::Forward, ShotDirection::Reverse],
Self::Excluded => &[],
}
}
pub fn from_direction(direction: ShotDirection) -> Self {
match direction {
ShotDirection::Forward => Self::Forward,
ShotDirection::Reverse => Self::Reverse,
}
}
pub fn single_direction(self) -> Option<ShotDirection> {
match self {
Self::Forward => Some(ShotDirection::Forward),
Self::Reverse => Some(ShotDirection::Reverse),
Self::Both | Self::Excluded => None,
}
}
}
impl<'de> Deserialize<'de> for ShotEntryMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct Visitor;
impl<'de> de::Visitor<'de> for Visitor {
type Value = ShotEntryMode;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("forward, reverse, both, excluded, or a legacy boolean")
}
fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(if value {
ShotEntryMode::Forward
} else {
ShotEntryMode::Excluded
})
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
match value {
"forward" => Ok(ShotEntryMode::Forward),
"reverse" => Ok(ShotEntryMode::Reverse),
"both" => Ok(ShotEntryMode::Both),
"excluded" | "none" => Ok(ShotEntryMode::Excluded),
other => Err(E::unknown_variant(
other,
&["forward", "reverse", "both", "excluded"],
)),
}
}
}
deserializer.deserialize_any(Visitor)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ShotEntry {
#[serde(default, alias = "clipId", alias = "cueId")]
pub entry_id: String,
pub shot_id: String,
#[serde(alias = "mode")]
pub direction: ShotEntryMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shot_index: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_speed_cm_s: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotation_speed_deg_s: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hold_duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub travel_duration_ms: Option<u64>,
}
impl ShotEntry {
pub fn directions(&self) -> &'static [ShotDirection] {
self.direction.directions()
}
pub fn capture_key(&self, direction: ShotDirection) -> String {
let direction_name = direction.label().to_ascii_lowercase();
if self.entry_id.trim().is_empty() {
format!("{}:{direction_name}", self.shot_id)
} else if self.direction == ShotEntryMode::Both {
format!("{}:{direction_name}", self.entry_id)
} else {
self.entry_id.clone()
}
}
pub fn from_shot(shot: &Shot, direction: ShotEntryMode) -> Self {
Self {
entry_id: String::new(),
shot_id: shot.id.to_string(),
direction,
shot_index: Some(shot.shot_index),
translation_speed_cm_s: Some(shot.translation_speed_cm_s),
rotation_speed_deg_s: Some(shot.rotation_speed_deg_s),
hold_duration_ms: Some(shot.hold_duration_ms),
travel_duration_ms: Some(shot.effective_travel_duration_ms()),
}
}
pub fn from_shot_with_id(
shot: &Shot,
direction: ShotDirection,
entry_id: impl Into<String>,
) -> Self {
let mut entry = Self::from_shot(shot, ShotEntryMode::from_direction(direction));
entry.entry_id = entry_id.into();
entry
}
pub fn resolved_shot(&self, shot: &Shot) -> Shot {
let mut resolved = shot.clone();
resolved.shot_index = self.shot_index.unwrap_or(shot.shot_index);
resolved.translation_speed_cm_s = self
.translation_speed_cm_s
.unwrap_or(shot.translation_speed_cm_s);
resolved.rotation_speed_deg_s = self
.rotation_speed_deg_s
.unwrap_or(shot.rotation_speed_deg_s);
resolved.hold_duration_ms = self.hold_duration_ms.unwrap_or(shot.hold_duration_ms);
resolved.travel_duration_ms = self
.travel_duration_ms
.filter(|duration| *duration > 0)
.unwrap_or_else(|| shot.effective_travel_duration_ms());
resolved
}
pub fn set_parameters_from_shot(&mut self, shot: &Shot) {
self.shot_index = Some(shot.shot_index);
self.translation_speed_cm_s = Some(shot.translation_speed_cm_s);
self.rotation_speed_deg_s = Some(shot.rotation_speed_deg_s);
self.hold_duration_ms = Some(shot.hold_duration_ms);
self.travel_duration_ms = Some(shot.effective_travel_duration_ms());
}
}
#[myko_macros::myko_item]
pub struct Timeline {
#[serde(default)]
pub library_id: String,
#[serde(default)]
pub streamer_id: String,
pub name: String,
#[serde(default, alias = "version")]
pub revision: u32,
#[serde(alias = "clips", alias = "cues")]
pub entries: Vec<ShotEntry>,
pub sort_order: u32,
}
impl Timeline {
pub fn legacy_default_id(library_id: &str) -> String {
format!("{library_id}:timeline:default")
}
pub fn shared_legacy_default_id() -> String {
Self::legacy_default_id(DEFAULT_SHOT_LIBRARY_ID)
}
pub fn has_legacy_default_identity(&self) -> bool {
let id = self.id.as_ref();
id.ends_with(":timeline:default") || id.ends_with(":shot-list:default")
}
pub fn has_legacy_default_name(&self) -> bool {
matches!(
self.name.trim().to_ascii_lowercase().as_str(),
"default timeline" | "default shot list"
)
}
pub fn effective_library_id(&self) -> &str {
effective_library_id(&self.library_id)
}
pub fn next_revision(&self) -> u32 {
self.revision.saturating_add(1).max(1)
}
pub fn normalize_entries(&mut self) {
let timeline_id = self.id.to_string();
let mut seen_ids = std::collections::HashSet::new();
let mut normalized = Vec::new();
for (position, entry) in std::mem::take(&mut self.entries).into_iter().enumerate() {
if entry.shot_id.trim().is_empty() || entry.direction == ShotEntryMode::Excluded {
continue;
}
let directions = entry.direction.directions();
for direction in directions {
let mut instance = entry.clone();
instance.direction = ShotEntryMode::from_direction(*direction);
let direction_name = direction.label().to_ascii_lowercase();
let base_id = if entry.entry_id.trim().is_empty() {
format!("{timeline_id}:entry:{position}")
} else {
entry.entry_id.trim().to_owned()
};
let candidate = if directions.len() > 1 {
format!("{base_id}:{direction_name}")
} else {
base_id
};
let mut entry_id = candidate.clone();
let mut collision = 2_u32;
while !seen_ids.insert(entry_id.clone()) {
entry_id = format!("{candidate}:{collision}");
collision = collision.saturating_add(1);
}
instance.entry_id = entry_id;
normalized.push(instance);
}
}
self.entries = normalized;
}
pub fn backfill_entry_parameters(&mut self, shots: &[Shot]) -> bool {
let by_id = shots
.iter()
.map(|shot| (shot.id.to_string(), shot))
.collect::<std::collections::HashMap<_, _>>();
let mut changed = false;
for entry in &mut self.entries {
let Some(shot) = by_id.get(&entry.shot_id) else {
continue;
};
if entry.shot_index.is_none()
|| entry.translation_speed_cm_s.is_none()
|| entry.rotation_speed_deg_s.is_none()
|| entry.hold_duration_ms.is_none()
|| entry
.travel_duration_ms
.is_none_or(|duration| duration == 0)
{
let resolved = entry.resolved_shot(shot);
entry.set_parameters_from_shot(&resolved);
changed = true;
}
}
changed
}
pub fn add_entry(&mut self, shot_id: &str, direction: ShotEntryMode) {
if direction == ShotEntryMode::Excluded {
return;
}
self.entries.push(ShotEntry {
entry_id: String::new(),
shot_id: shot_id.to_owned(),
direction,
shot_index: None,
translation_speed_cm_s: None,
rotation_speed_deg_s: None,
hold_duration_ms: None,
travel_duration_ms: None,
});
self.normalize_entries();
}
pub fn add_shot_entry(&mut self, shot: &Shot, direction: ShotEntryMode) {
if direction == ShotEntryMode::Excluded {
return;
}
self.entries.push(ShotEntry::from_shot(shot, direction));
self.normalize_entries();
}
pub fn add_shot_entry_instance(
&mut self,
shot: &Shot,
direction: ShotDirection,
entry_id: impl Into<String>,
) {
self.entries
.push(ShotEntry::from_shot_with_id(shot, direction, entry_id));
self.normalize_entries();
}
pub fn remove_entry(&mut self, entry_id: &str) {
self.entries.retain(|entry| entry.entry_id != entry_id);
self.normalize_entries();
}
pub fn set_entry_direction(&mut self, entry_id: &str, direction: ShotDirection) {
if let Some(entry) = self
.entries
.iter_mut()
.find(|entry| entry.entry_id == entry_id)
{
entry.direction = ShotEntryMode::from_direction(direction);
}
}
pub fn capture_count(&self) -> usize {
self.entries
.iter()
.map(|entry| entry.directions().len())
.sum()
}
}