pub mod cam_pref;
pub mod camera_home;
pub mod collection;
pub mod collection_membership;
pub mod commands;
pub mod control_lock;
pub mod frame_capture;
pub mod frame_capture_status;
pub mod frame_capture_target;
pub(crate) mod legacy_capture_run;
pub(crate) mod legacy_shot_list;
pub mod otio;
pub mod previs_dlss;
pub mod previs_dlss_status;
pub mod recording_job;
pub mod recording_job_request;
pub mod recording_job_status;
pub mod recording_plan;
pub mod recording_request;
pub mod recording_status;
pub mod reports;
pub mod shot;
pub mod stream;
pub mod timeline;
pub mod viewer;
pub use pulse_capture_types::{
CaptureCollection, CaptureCollectionId, CaptureContext, CaptureLabel, CaptureLibraryId,
CollectionKind, CollectionPath, ContentRevision, EditorialContextError,
PixelstreamCollectionPathSegment, PixelstreamCollectionRef,
};
pub use cam_pref::*;
pub use camera_home::*;
pub use collection::*;
pub use collection_membership::*;
pub use commands::*;
pub use control_lock::*;
pub use frame_capture::*;
pub use frame_capture_status::*;
pub use frame_capture_target::*;
pub use otio::*;
pub use previs_dlss::*;
pub use previs_dlss_status::*;
pub use recording_job::*;
pub use recording_job_request::*;
pub use recording_job_status::*;
pub use recording_plan::*;
pub use recording_request::*;
pub use recording_status::*;
pub use reports::*;
pub use shot::*;
pub use stream::*;
pub use timeline::*;
pub use viewer::*;
pub fn link() {}
#[cfg(test)]
mod deser_tests {
use crate::{
collection::CollectionPathSegment,
commands::{JoinStream, MigrateOtioTimelines},
control_lock::ControlLock,
recording_job_request::{RecordingJobRequest, RecordingJobRequestId},
recording_job_status::{RecordingJobStatus, RecordingJobStatusId},
recording_plan::{RecordingJobAction, RecordingJobPhase, ShotEntryPlan},
recording_request::{CaptureKind, RecordingKind, RecordingRequest, RecordingRequestId},
recording_status::{RecordingState, RecordingStatus, RecordingStatusId},
shot::{Shot, ShotId, ShotKind, DEFAULT_SHOT_LIBRARY_ID},
timeline::{ShotDirection, ShotEntry, ShotEntryMode, Timeline, TimelineId},
viewer::Viewer,
CaptureContext, CaptureLabel, CollectionPath, ContentRevision, StoredCaptureContext,
};
#[test]
fn shared_capture_context_uses_cluster_wire_contract() {
let context = CaptureContext {
collection: CollectionPath::try_from("cycle_4/moment_2".to_owned())
.unwrap()
.into(),
label: Some(CaptureLabel::try_from("hero orbit".to_owned()).unwrap()),
content_revision: Some(ContentRevision::try_from("workspace:42".to_owned()).unwrap()),
};
let value = serde_json::to_value(context).unwrap();
assert_eq!(value["collection"], "cycle_4/moment_2");
assert_eq!(value["label"], "hero orbit");
assert_eq!(value["contentRevision"], "workspace:42");
}
#[test]
fn stored_capture_context_is_wire_identical_to_the_bare_contract() {
let context = CaptureContext {
collection: CollectionPath::try_from("cycle_4/moment_2".to_owned())
.unwrap()
.into(),
label: Some(CaptureLabel::try_from("hero orbit".to_owned()).unwrap()),
content_revision: Some(ContentRevision::try_from("workspace:42".to_owned()).unwrap()),
};
let bare = serde_json::to_value(&context).unwrap();
let stored = serde_json::to_value(StoredCaptureContext(context.clone())).unwrap();
assert_eq!(bare, stored, "newtype changed the serialized form");
let replayed: StoredCaptureContext = serde_json::from_value(bare).unwrap();
assert_eq!(replayed.0, context);
}
#[test]
fn recording_job_status_replays_a_pre_newtype_event() {
let json = r#"{"streamerId":"render-13","jobId":"job-1","captureContext":{"collection":"cycle_4/moment_2","label":"hero orbit","contentRevision":"workspace:42"},"id":"render-13"}"#;
let status = serde_json::from_str::<RecordingJobStatus>(json)
.expect("pre-newtype RecordingJobStatus must still deserialize");
let context = status
.capture_context
.as_ref()
.expect("captureContext preserved");
assert_eq!(
context.label.as_ref().map(CaptureLabel::as_str),
Some("hero orbit")
);
let reserialized = serde_json::to_value(&status).unwrap();
assert_eq!(
reserialized["captureContext"]["collection"],
"cycle_4/moment_2"
);
}
#[test]
fn controllock_wire_deser() {
let j = r#"{"streamId":"s1","viewerId":"v1","clientId":"c1","id":"s1"}"#;
let r = serde_json::from_str::<ControlLock>(j);
assert!(r.is_ok(), "ControlLock deser failed: {:?}", r.err());
}
#[test]
fn viewer_wire_deser() {
let j = r#"{"streamId":"s1","viewerId":"v1","name":"n","color":"red","cursor":null,"clientId":"c1","id":"s1:v1"}"#;
let r = serde_json::from_str::<Viewer>(j);
assert!(r.is_ok(), "Viewer deser failed: {:?}", r.err());
}
#[test]
fn legacy_guest_join_stream_defaults_optional_identity() {
let j = r#"{"streamId":"s1","viewerId":"v1","name":"Anonymous Puffin","color":"red"}"#;
let join =
serde_json::from_str::<JoinStream>(j).expect("legacy JoinStream must deserialize");
assert_eq!(join.name, "Anonymous Puffin");
assert!(join.identity_issuer.is_none());
assert!(join.identity_subject.is_none());
assert!(join.avatar_url.is_none());
}
#[test]
fn shot_kind_uses_film_accurate_static_wire_term() {
assert_eq!(serde_json::to_value(ShotKind::Static).unwrap(), "static");
for legacy in ["stationary", "preset", "preset_hold"] {
let encoded = format!("\"{legacy}\"");
assert_eq!(
serde_json::from_str::<ShotKind>(&encoded).unwrap(),
ShotKind::Static
);
}
assert_eq!(
Shot::stable_id("shared", &ShotKind::Static, "Wide"),
"shared:shot:static:Wide"
);
}
#[test]
fn recording_status_uses_only_the_0_5_wire_contract() {
let status = RecordingStatus {
id: RecordingStatusId::from("render-13".to_owned()),
streamer_id: "render-13".to_owned(),
state: RecordingState::SavedToNas,
file_name: "capture.mp4".to_owned(),
nas_path: "/mnt/nas/recordings/pixelstream/capture.mkv".to_owned(),
dropbox_path: "recordings/pixelstream/cycle_3/dailies/capture.mp4".to_owned(),
error: String::new(),
started_at_ms: 1_785_460_000_000,
};
let value = serde_json::to_value(status).unwrap();
assert_eq!(value["state"], "ready_for_handoff");
assert_eq!(value["fileName"], "capture.mp4");
assert_eq!(value["startedAtMs"], 1_785_460_000_000_u64);
assert_eq!(
value["nasPath"],
"/mnt/nas/recordings/pixelstream/capture.mkv"
);
assert_eq!(
value["dropboxPath"],
"recordings/pixelstream/cycle_3/dailies/capture.mp4"
);
assert!(value.get("path").is_none());
assert!(value.get("startedAt").is_none());
let old = r#"{
"id":"render-13",
"streamerId":"render-13",
"state":"done",
"path":"capture.mp4",
"startedAt":1785460000000
}"#;
assert!(serde_json::from_str::<RecordingStatus>(old).is_err());
}
#[test]
fn recording_request_direction_is_backward_compatible_and_explicit() {
let legacy = r#"{
"id":"render-13",
"streamerId":"render-13",
"active":true,
"rig":"Hero Push Forward"
}"#;
let legacy = serde_json::from_str::<RecordingRequest>(legacy).unwrap();
assert_eq!(legacy.travel_direction, None);
assert_eq!(legacy.shot_index, None);
assert_eq!(legacy.capture_kind, RecordingKind::Video);
let reverse = RecordingRequest {
id: RecordingRequestId::from("render-13".to_owned()),
streamer_id: "render-13".to_owned(),
active: true,
capture_kind: RecordingKind::Screenshot,
rig: "Hero Push Forward".to_owned(),
preset: String::new(),
stream_name: String::new(),
travel_direction: Some(ShotDirection::Reverse),
shot_index: Some(8),
take_number: 1,
entry_id: "entry-8".to_owned(),
timeline_id: String::new(),
timeline_name: String::new(),
timeline_revision: 0,
collection_path: Vec::new(),
requested_at_ms: 1,
};
let value = serde_json::to_value(reverse).unwrap();
assert_eq!(value["travelDirection"], "reverse");
assert_eq!(value["shotIndex"], 8);
assert_eq!(value["captureKind"], "screenshot");
}
#[test]
fn recording_job_wire_contract_is_explicit_and_resume_safe() {
assert_eq!(
serde_json::to_value(RecordingJobAction::Pause).unwrap(),
"pause"
);
let item = ShotEntryPlan {
entry_id: "entry-1".to_owned(),
shot_id: "shot-1".to_owned(),
name: "Floor Dolly".to_owned(),
shot_index: Some(4),
kind: ShotKind::Moving,
target_name: "Floor Dolly".to_owned(),
translation_speed_cm_s: 10.0,
rotation_speed_deg_s: 2.0,
hold_duration_ms: 5_000,
travel_duration_ms: 30_000,
direction: ShotDirection::Reverse,
next_take_number: 1,
open_ended: false,
};
let request = RecordingJobRequest {
id: RecordingJobRequestId::from("render-02".to_owned()),
streamer_id: "render-02".to_owned(),
job_id: "job-1".to_owned(),
command_id: "command-1".to_owned(),
action: RecordingJobAction::Start,
capture_kind: CaptureKind::Video,
capture_context: None,
timeline_id: "timeline-1".to_owned(),
timeline_name: "Client selects".to_owned(),
timeline_revision: 4,
collection_id: "launch-film".to_owned(),
collection_path: vec![
CollectionPathSegment {
collection_id: "autumn-campaign".to_owned(),
name: "Autumn campaign".to_owned(),
},
CollectionPathSegment {
collection_id: "launch-film".to_owned(),
name: "Launch film".to_owned(),
},
],
entries: vec![item.clone()],
preset_duration_ms: 0,
translation_speed_cm_s: 0.0,
rotation_speed_deg_s: 0.0,
requested_at_ms: 10,
};
let value = serde_json::to_value(request).unwrap();
assert_eq!(value["action"], "start");
assert_eq!(value["timelineId"], "timeline-1");
assert_eq!(value["timelineName"], "Client selects");
assert_eq!(value["timelineRevision"], 4);
assert!(value.get("shotListId").is_none());
assert_eq!(value["collectionId"], "launch-film");
assert_eq!(value["collectionPath"][0]["name"], "Autumn campaign");
assert_eq!(value["collectionPath"][1]["name"], "Launch film");
assert_eq!(value["entries"][0]["translationSpeedCmS"], 10.0);
assert_eq!(value["entries"][0]["rotationSpeedDegS"], 2.0);
assert_eq!(value["entries"][0]["direction"], "reverse");
assert_eq!(value["entries"][0]["shotIndex"], 4);
let mut status = RecordingJobStatus {
id: RecordingJobStatusId::from("render-02".to_owned()),
streamer_id: "render-02".to_owned(),
job_id: "job-1".to_owned(),
capture_kind: CaptureKind::Video,
capture_context: None,
timeline_id: "timeline-1".to_owned(),
timeline_name: "Client selects".to_owned(),
timeline_revision: 4,
collection_id: "launch-film".to_owned(),
collection_path: vec![CollectionPathSegment {
collection_id: "launch-film".to_owned(),
name: "Launch film".to_owned(),
}],
phase: RecordingJobPhase::Paused,
pause_requested: false,
entries: vec![item],
index: 0,
completed: 0,
error: "resume required".to_owned(),
updated_at_ms: 11,
elapsed_ms: 4_000,
estimated_total_ms: 90_000,
estimated_remaining_ms: 86_000,
takes: Vec::new(),
started_at_ms: 1,
};
assert!(status.can_resume());
assert_eq!(status.resume_progress(), (1, 1));
assert_eq!(serde_json::to_value(&status).unwrap()["phase"], "paused");
status.phase = RecordingJobPhase::Canceled;
assert!(!status.can_resume());
status.phase = RecordingJobPhase::Complete;
status.error = "1 take rejected; the job continued".to_owned();
assert!(status.can_resume());
assert_eq!(
status.summary(),
"Recording complete with warnings · 0 of 1 takes durable on NAS · 1 take rejected; the job continued"
);
}
#[test]
fn timeline_mode_migrates_legacy_enabled_values() {
assert_eq!(
serde_json::from_str::<ShotEntryMode>("true").unwrap(),
ShotEntryMode::Forward
);
assert_eq!(
serde_json::from_str::<ShotEntryMode>("false").unwrap(),
ShotEntryMode::Excluded
);
assert_eq!(
serde_json::from_str::<ShotEntryMode>(r#""both""#).unwrap(),
ShotEntryMode::Both
);
let legacy_shot = r#"{
"id":"render-02:moving:Floor Dolly",
"streamerId":"render-02",
"name":"Floor Dolly",
"kind":"moving",
"targetName":"Floor Dolly",
"translationSpeedCmS":10.0,
"rotationSpeedDegS":2.0,
"holdDurationMs":5000,
"batchMode":"excluded",
"sortOrder":0
}"#;
let shot = serde_json::from_str::<Shot>(legacy_shot).unwrap();
assert_eq!(shot.effective_library_id(), "shared");
assert_eq!(shot.default_entry_mode, ShotEntryMode::Excluded);
assert_eq!(shot.shot_index, 0);
let shot_wire = serde_json::to_value(shot).unwrap();
assert_eq!(shot_wire["sortOrder"], 0);
assert!(shot_wire.get("shotIndex").is_none());
let legacy_plan = r#"{
"shotId":"shot-1",
"name":"Floor Dolly",
"kind":"moving",
"targetName":"Floor Dolly",
"translationSpeedCmS":10.0,
"rotationSpeedDegS":2.0,
"holdDurationMs":5000
}"#;
let legacy_plan = serde_json::from_str::<ShotEntryPlan>(legacy_plan).unwrap();
assert_eq!(legacy_plan.direction, ShotDirection::Forward);
assert_eq!(legacy_plan.shot_index, None);
}
#[test]
fn legacy_clips_without_overrides_remain_compatible() {
let mut timeline = Timeline {
id: TimelineId::from(Timeline::legacy_default_id("render-02")),
library_id: "shared".to_owned(),
streamer_id: "render-02".to_owned(),
name: "Client selects".to_owned(),
revision: 3,
entries: vec![ShotEntry {
shot_id: "shot-a".to_owned(),
direction: ShotEntryMode::Both,
..Default::default()
}],
sort_order: 0,
};
timeline.normalize_entries();
assert_eq!(timeline.entries.len(), 2);
assert_eq!(timeline.capture_count(), 2);
assert_eq!(timeline.next_revision(), 4);
assert!(timeline
.entries
.iter()
.all(|entry| !entry.entry_id.is_empty()));
assert_eq!(timeline.entries[0].direction, ShotEntryMode::Forward);
assert_eq!(timeline.entries[1].direction, ShotEntryMode::Reverse);
timeline.add_entry("shot-b", ShotEntryMode::Forward);
let first_entry_id = timeline.entries[0].entry_id.clone();
timeline.remove_entry(&first_entry_id);
assert_eq!(timeline.entries.len(), 2);
let value = serde_json::to_value(timeline).unwrap();
assert_eq!(value["entries"][0]["shotId"], "shot-a");
assert!(value["entries"][0].get("translationSpeedCmS").is_none());
assert!(value["entries"][0].get("rotationSpeedDegS").is_none());
assert!(value.get("clips").is_none());
assert!(value["entries"][0].get("cueId").is_none());
assert!(value["entries"][0].get("clipId").is_none());
assert!(value["entries"][0].get("mode").is_none());
}
#[test]
fn the_same_shot_has_independent_parameters_in_each_timeline() {
let shot = Shot {
id: ShotId::from("shared:shot:moving:hero"),
library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
streamer_id: String::new(),
name: "Hero".to_owned(),
kind: ShotKind::Moving,
target_name: "Hero".to_owned(),
translation_speed_cm_s: 10.0,
rotation_speed_deg_s: 2.0,
hold_duration_ms: 5_000,
travel_duration_ms: 30_000,
default_entry_mode: ShotEntryMode::Forward,
shot_index: 0,
};
let mut fast = ShotEntry::from_shot(&shot, ShotEntryMode::Forward);
let mut slow = ShotEntry::from_shot(&shot, ShotEntryMode::Forward);
fast.translation_speed_cm_s = Some(20.0);
fast.travel_duration_ms = Some(15_000);
slow.translation_speed_cm_s = Some(5.0);
slow.travel_duration_ms = Some(60_000);
assert_eq!(fast.resolved_shot(&shot).translation_speed_cm_s, 20.0);
assert_eq!(slow.resolved_shot(&shot).translation_speed_cm_s, 5.0);
assert_eq!(fast.resolved_shot(&shot).travel_duration_ms, 15_000);
assert_eq!(slow.resolved_shot(&shot).travel_duration_ms, 60_000);
assert_eq!(shot.translation_speed_cm_s, 10.0);
}
#[test]
fn timeline_normalizes_membership_without_persisting_a_second_index() {
let legacy = r#"{
"id":"render-02:timeline:legacy",
"streamerId":"render-02",
"name":"Legacy",
"version":0,
"entries":[
{"shotId":"shot-b","mode":"forward"},
{"shotId":"shot-a","mode":"reverse"}
],
"sortOrder":0
}"#;
let mut timeline: Timeline = serde_json::from_str(legacy).unwrap();
timeline.normalize_entries();
assert_eq!(timeline.entries[0].shot_id, "shot-b");
assert_eq!(timeline.entries[1].shot_id, "shot-a");
assert!(serde_json::to_value(timeline).unwrap()["entries"][0]
.get("index")
.is_none());
}
#[test]
fn timeline_migration_accepts_the_myko_transaction_envelope() {
serde_json::from_str::<MigrateOtioTimelines>(r#"{"tx":"migration-tx"}"#)
.expect("migration command must accept Myko's map-shaped envelope");
}
}
myko::impl_filterable_eq!(
crate::recording_plan::RecordingJobAction,
crate::recording_plan::RecordingJobPhase,
crate::recording_request::RecordingKind,
crate::recording_status::RecordingState,
crate::shot::ShotKind,
crate::timeline::ShotDirection,
crate::timeline::ShotEntryMode,
crate::frame_capture::FrameCapturePhase,
crate::previs_dlss::DlssQuality,
crate::previs_dlss::DlssReconstruction,
);
myko::impl_filterable_opaque!(
crate::frame_capture::FrameCaptureTarget,
crate::previs_dlss::DlssSettings,
crate::previs_dlss::PrevisProcessTarget,
crate::previs_dlss_status::DlssConvergence,
StoredCaptureContext,
);
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, myko::TS)]
#[serde(transparent)]
#[ts(type = "unknown")]
pub struct StoredCaptureContext(pub CaptureContext);
impl From<CaptureContext> for StoredCaptureContext {
fn from(context: CaptureContext) -> Self {
Self(context)
}
}
impl From<StoredCaptureContext> for CaptureContext {
fn from(stored: StoredCaptureContext) -> Self {
stored.0
}
}
impl std::ops::Deref for StoredCaptureContext {
type Target = CaptureContext;
fn deref(&self) -> &Self::Target {
&self.0
}
}