pulse-pixelstream-types 0.9.1

Shared Myko entity and command types for the Pulse Pixelstream recording cell.
Documentation
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",
        }
    }
}

/// Legacy wire policy for directional capture membership. Newly-authored shot
/// entries use one direction each; `Both` is accepted only to migrate older
/// rows into two independent entries.
#[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,
        }
    }
}

/// Deserialize both the string policy and the legacy boolean field. With
/// `alias = "enabled"` on a containing field, persisted values migrate without
/// a separate data rewrite.
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)
    }
}

/// One persistent appearance of a Shot in a reusable timeline.
///
/// The referenced Shot supplies identity and library defaults. Optional entry
/// values are per-Timeline overrides, allowing the same camera target to execute at a
/// different speed or duration in different timelines. Missing values exist
/// only for wire compatibility and are materialized server-side on discovery,
/// import, save, or capture start.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ShotEntry {
    /// Stable identity for this particular appearance in a timeline. Multiple
    /// clips may reference the same Shot, just as multiple OTIO Clips may
    /// reference the same media/source object.
    #[serde(default, alias = "clipId", alias = "cueId")]
    pub entry_id: String,
    pub shot_id: String,
    /// Playback/capture direction for this ShotEntry.
    #[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());
    }
}

/// A named, reusable timeline in a shared definition library.
///
/// Like an OTIO Track, `entries` is an ordered sequence of shot appearances.
/// A Shot is the reusable source definition; any number of entries may reference
/// it, and every entry owns its direction and capture parameters independently.
/// `shot_index` is an authored production label, not a competing sort key.
#[myko_macros::myko_item]
pub struct Timeline {
    /// Shared definition scope. Empty on legacy rows and interpreted as the
    /// default shared library.
    #[serde(default)]
    pub library_id: String,
    /// Legacy provenance retained for wire and persisted-row compatibility.
    /// The active stream is supplied only when a RecordingJob starts.
    #[serde(default)]
    pub streamer_id: String,
    pub name: String,
    /// Immutable-program revision. Editing the timeline advances this value;
    /// recording it does not. Legacy rows stored this as `version`.
    #[serde(default, alias = "version")]
    pub revision: u32,
    /// Ordered shot appearances. They project to OTIO Clips only at the
    /// interchange boundary. Legacy persistence rows used `clips` or `cues`.
    #[serde(alias = "clips", alias = "cues")]
    pub entries: Vec<ShotEntry>,
    pub sort_order: u32,
}

impl Timeline {
    /// Historic identity used by the retired automatic shot-list workflow.
    /// Kept only so persisted rows and their references can be reconciled.
    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)
    }

    /// Normalize legacy memberships into ordered ShotEntry instances. A legacy
    /// `Both` membership becomes two independently addressable entries, while
    /// repeated references to the same Shot are deliberately preserved.
    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;
    }

    /// Materialize every legacy optional entry value from its referenced Shot.
    /// Once this returns true, later Shot-default edits cannot leak across timelines.
    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()
    }
}