1pub mod cam_pref;
9pub mod camera_home;
10pub mod collection;
11pub mod collection_membership;
12pub mod commands;
13pub mod control_lock;
14pub mod frame_capture;
15pub mod frame_capture_status;
16pub mod frame_capture_target;
17pub(crate) mod legacy_capture_run;
18pub(crate) mod legacy_shot_list;
19pub mod otio;
20pub mod previs_dlss;
21pub mod previs_dlss_status;
22pub mod recording_job;
23pub mod recording_job_request;
24pub mod recording_job_status;
25pub mod recording_plan;
26pub mod recording_request;
27pub mod recording_status;
28pub mod reports;
29pub mod shot;
30pub mod stream;
31pub mod timeline;
32pub mod viewer;
33
34pub use pulse_capture_types::{
37 CaptureCollection, CaptureCollectionId, CaptureContext, CaptureLabel, CaptureLibraryId,
38 CollectionKind, CollectionPath, ContentRevision, EditorialContextError,
39 PixelstreamCollectionPathSegment, PixelstreamCollectionRef,
40};
41
42pub use cam_pref::*;
43pub use camera_home::*;
44pub use collection::*;
45pub use collection_membership::*;
46pub use commands::*;
47pub use control_lock::*;
48pub use frame_capture::*;
49pub use frame_capture_status::*;
50pub use frame_capture_target::*;
51pub use otio::*;
52pub use previs_dlss::*;
53pub use previs_dlss_status::*;
54pub use recording_job::*;
55pub use recording_job_request::*;
56pub use recording_job_status::*;
57pub use recording_plan::*;
58pub use recording_request::*;
59pub use recording_status::*;
60pub use reports::*;
61pub use shot::*;
62pub use stream::*;
63pub use timeline::*;
64pub use viewer::*;
65
66pub fn link() {}
70
71#[cfg(test)]
72mod deser_tests {
73 use crate::{
74 collection::CollectionPathSegment,
75 commands::{JoinStream, MigrateOtioTimelines},
76 control_lock::ControlLock,
77 recording_job_request::{RecordingJobRequest, RecordingJobRequestId},
78 recording_job_status::{RecordingJobStatus, RecordingJobStatusId},
79 recording_plan::{RecordingJobAction, RecordingJobPhase, ShotEntryPlan},
80 recording_request::{CaptureKind, RecordingKind, RecordingRequest, RecordingRequestId},
81 recording_status::{RecordingState, RecordingStatus, RecordingStatusId},
82 shot::{Shot, ShotId, ShotKind, DEFAULT_SHOT_LIBRARY_ID},
83 timeline::{ShotDirection, ShotEntry, ShotEntryMode, Timeline, TimelineId},
84 viewer::Viewer,
85 CaptureContext, CaptureLabel, CollectionPath, ContentRevision, StoredCaptureContext,
86 };
87
88 #[test]
89 fn shared_capture_context_uses_cluster_wire_contract() {
90 let context = CaptureContext {
91 collection: CollectionPath::try_from("cycle_4/moment_2".to_owned())
92 .unwrap()
93 .into(),
94 label: Some(CaptureLabel::try_from("hero orbit".to_owned()).unwrap()),
95 content_revision: Some(ContentRevision::try_from("workspace:42".to_owned()).unwrap()),
96 };
97 let value = serde_json::to_value(context).unwrap();
98 assert_eq!(value["collection"], "cycle_4/moment_2");
99 assert_eq!(value["label"], "hero orbit");
100 assert_eq!(value["contentRevision"], "workspace:42");
101 }
102
103 #[test]
104 fn stored_capture_context_is_wire_identical_to_the_bare_contract() {
105 let context = CaptureContext {
110 collection: CollectionPath::try_from("cycle_4/moment_2".to_owned())
111 .unwrap()
112 .into(),
113 label: Some(CaptureLabel::try_from("hero orbit".to_owned()).unwrap()),
114 content_revision: Some(ContentRevision::try_from("workspace:42".to_owned()).unwrap()),
115 };
116 let bare = serde_json::to_value(&context).unwrap();
117 let stored = serde_json::to_value(StoredCaptureContext(context.clone())).unwrap();
118 assert_eq!(bare, stored, "newtype changed the serialized form");
119
120 let replayed: StoredCaptureContext = serde_json::from_value(bare).unwrap();
122 assert_eq!(replayed.0, context);
123 }
124
125 #[test]
126 fn recording_job_status_replays_a_pre_newtype_event() {
127 let json = r#"{"streamerId":"render-13","jobId":"job-1","captureContext":{"collection":"cycle_4/moment_2","label":"hero orbit","contentRevision":"workspace:42"},"id":"render-13"}"#;
129 let status = serde_json::from_str::<RecordingJobStatus>(json)
130 .expect("pre-newtype RecordingJobStatus must still deserialize");
131 let context = status
132 .capture_context
133 .as_ref()
134 .expect("captureContext preserved");
135 assert_eq!(
136 context.label.as_ref().map(CaptureLabel::as_str),
137 Some("hero orbit")
138 );
139 let reserialized = serde_json::to_value(&status).unwrap();
141 assert_eq!(
142 reserialized["captureContext"]["collection"],
143 "cycle_4/moment_2"
144 );
145 }
146
147 #[test]
149 fn controllock_wire_deser() {
150 let j = r#"{"streamId":"s1","viewerId":"v1","clientId":"c1","id":"s1"}"#;
151 let r = serde_json::from_str::<ControlLock>(j);
152 assert!(r.is_ok(), "ControlLock deser failed: {:?}", r.err());
153 }
154
155 #[test]
156 fn viewer_wire_deser() {
157 let j = r#"{"streamId":"s1","viewerId":"v1","name":"n","color":"red","cursor":null,"clientId":"c1","id":"s1:v1"}"#;
158 let r = serde_json::from_str::<Viewer>(j);
159 assert!(r.is_ok(), "Viewer deser failed: {:?}", r.err());
160 }
161
162 #[test]
163 fn legacy_guest_join_stream_defaults_optional_identity() {
164 let j = r#"{"streamId":"s1","viewerId":"v1","name":"Anonymous Puffin","color":"red"}"#;
165 let join =
166 serde_json::from_str::<JoinStream>(j).expect("legacy JoinStream must deserialize");
167 assert_eq!(join.name, "Anonymous Puffin");
168 assert!(join.identity_issuer.is_none());
169 assert!(join.identity_subject.is_none());
170 assert!(join.avatar_url.is_none());
171 }
172
173 #[test]
174 fn shot_kind_uses_film_accurate_static_wire_term() {
175 assert_eq!(serde_json::to_value(ShotKind::Static).unwrap(), "static");
176 for legacy in ["stationary", "preset", "preset_hold"] {
177 let encoded = format!("\"{legacy}\"");
178 assert_eq!(
179 serde_json::from_str::<ShotKind>(&encoded).unwrap(),
180 ShotKind::Static
181 );
182 }
183 assert_eq!(
184 Shot::stable_id("shared", &ShotKind::Static, "Wide"),
185 "shared:shot:static:Wide"
186 );
187 }
188
189 #[test]
190 fn recording_status_uses_only_the_0_5_wire_contract() {
191 let status = RecordingStatus {
192 id: RecordingStatusId::from("render-13".to_owned()),
193 streamer_id: "render-13".to_owned(),
194 state: RecordingState::SavedToNas,
195 file_name: "capture.mp4".to_owned(),
196 nas_path: "/mnt/nas/recordings/pixelstream/capture.mkv".to_owned(),
197 dropbox_path: "recordings/pixelstream/cycle_3/dailies/capture.mp4".to_owned(),
198 error: String::new(),
199 started_at_ms: 1_785_460_000_000,
200 };
201 let value = serde_json::to_value(status).unwrap();
202 assert_eq!(value["state"], "ready_for_handoff");
203 assert_eq!(value["fileName"], "capture.mp4");
204 assert_eq!(value["startedAtMs"], 1_785_460_000_000_u64);
205 assert_eq!(
209 value["nasPath"],
210 "/mnt/nas/recordings/pixelstream/capture.mkv"
211 );
212 assert_eq!(
213 value["dropboxPath"],
214 "recordings/pixelstream/cycle_3/dailies/capture.mp4"
215 );
216 assert!(value.get("path").is_none());
217 assert!(value.get("startedAt").is_none());
218
219 let old = r#"{
220 "id":"render-13",
221 "streamerId":"render-13",
222 "state":"done",
223 "path":"capture.mp4",
224 "startedAt":1785460000000
225 }"#;
226 assert!(serde_json::from_str::<RecordingStatus>(old).is_err());
227 }
228
229 #[test]
230 fn recording_request_direction_is_backward_compatible_and_explicit() {
231 let legacy = r#"{
232 "id":"render-13",
233 "streamerId":"render-13",
234 "active":true,
235 "rig":"Hero Push Forward"
236 }"#;
237 let legacy = serde_json::from_str::<RecordingRequest>(legacy).unwrap();
238 assert_eq!(legacy.travel_direction, None);
239 assert_eq!(legacy.shot_index, None);
240 assert_eq!(legacy.capture_kind, RecordingKind::Video);
241
242 let reverse = RecordingRequest {
243 id: RecordingRequestId::from("render-13".to_owned()),
244 streamer_id: "render-13".to_owned(),
245 active: true,
246 capture_kind: RecordingKind::Screenshot,
247 rig: "Hero Push Forward".to_owned(),
248 preset: String::new(),
249 stream_name: String::new(),
250 travel_direction: Some(ShotDirection::Reverse),
251 shot_index: Some(8),
252 take_number: 1,
253 entry_id: "entry-8".to_owned(),
254 timeline_id: String::new(),
255 timeline_name: String::new(),
256 timeline_revision: 0,
257 collection_path: Vec::new(),
258 requested_at_ms: 1,
259 };
260 let value = serde_json::to_value(reverse).unwrap();
261 assert_eq!(value["travelDirection"], "reverse");
262 assert_eq!(value["shotIndex"], 8);
263 assert_eq!(value["captureKind"], "screenshot");
264 }
265
266 #[test]
267 fn recording_job_wire_contract_is_explicit_and_resume_safe() {
268 assert_eq!(
269 serde_json::to_value(RecordingJobAction::Pause).unwrap(),
270 "pause"
271 );
272 let item = ShotEntryPlan {
273 entry_id: "entry-1".to_owned(),
274 shot_id: "shot-1".to_owned(),
275 name: "Floor Dolly".to_owned(),
276 shot_index: Some(4),
277 kind: ShotKind::Moving,
278 target_name: "Floor Dolly".to_owned(),
279 translation_speed_cm_s: 10.0,
280 rotation_speed_deg_s: 2.0,
281 hold_duration_ms: 5_000,
282 travel_duration_ms: 30_000,
283 direction: ShotDirection::Reverse,
284 next_take_number: 1,
285 open_ended: false,
286 };
287 let request = RecordingJobRequest {
288 id: RecordingJobRequestId::from("render-02".to_owned()),
289 streamer_id: "render-02".to_owned(),
290 job_id: "job-1".to_owned(),
291 command_id: "command-1".to_owned(),
292 action: RecordingJobAction::Start,
293 capture_kind: CaptureKind::Video,
294 capture_context: None,
295 timeline_id: "timeline-1".to_owned(),
296 timeline_name: "Client selects".to_owned(),
297 timeline_revision: 4,
298 collection_id: "launch-film".to_owned(),
299 collection_path: vec![
300 CollectionPathSegment {
301 collection_id: "autumn-campaign".to_owned(),
302 name: "Autumn campaign".to_owned(),
303 },
304 CollectionPathSegment {
305 collection_id: "launch-film".to_owned(),
306 name: "Launch film".to_owned(),
307 },
308 ],
309 entries: vec![item.clone()],
310 preset_duration_ms: 0,
311 translation_speed_cm_s: 0.0,
312 rotation_speed_deg_s: 0.0,
313 requested_at_ms: 10,
314 };
315 let value = serde_json::to_value(request).unwrap();
316 assert_eq!(value["action"], "start");
317 assert_eq!(value["timelineId"], "timeline-1");
318 assert_eq!(value["timelineName"], "Client selects");
319 assert_eq!(value["timelineRevision"], 4);
320 assert!(value.get("shotListId").is_none());
321 assert_eq!(value["collectionId"], "launch-film");
322 assert_eq!(value["collectionPath"][0]["name"], "Autumn campaign");
323 assert_eq!(value["collectionPath"][1]["name"], "Launch film");
324 assert_eq!(value["entries"][0]["translationSpeedCmS"], 10.0);
325 assert_eq!(value["entries"][0]["rotationSpeedDegS"], 2.0);
326 assert_eq!(value["entries"][0]["direction"], "reverse");
327 assert_eq!(value["entries"][0]["shotIndex"], 4);
328
329 let mut status = RecordingJobStatus {
330 id: RecordingJobStatusId::from("render-02".to_owned()),
331 streamer_id: "render-02".to_owned(),
332 job_id: "job-1".to_owned(),
333 capture_kind: CaptureKind::Video,
334 capture_context: None,
335 timeline_id: "timeline-1".to_owned(),
336 timeline_name: "Client selects".to_owned(),
337 timeline_revision: 4,
338 collection_id: "launch-film".to_owned(),
339 collection_path: vec![CollectionPathSegment {
340 collection_id: "launch-film".to_owned(),
341 name: "Launch film".to_owned(),
342 }],
343 phase: RecordingJobPhase::Paused,
344 pause_requested: false,
345 entries: vec![item],
346 index: 0,
347 completed: 0,
348 error: "resume required".to_owned(),
349 updated_at_ms: 11,
350 elapsed_ms: 4_000,
351 estimated_total_ms: 90_000,
352 estimated_remaining_ms: 86_000,
353 takes: Vec::new(),
354 started_at_ms: 1,
355 };
356 assert!(status.can_resume());
357 assert_eq!(status.resume_progress(), (1, 1));
358 assert_eq!(serde_json::to_value(&status).unwrap()["phase"], "paused");
359
360 status.phase = RecordingJobPhase::Canceled;
361 assert!(!status.can_resume());
362
363 status.phase = RecordingJobPhase::Complete;
364 status.error = "1 take rejected; the job continued".to_owned();
365 assert!(status.can_resume());
366 assert_eq!(
367 status.summary(),
368 "Recording complete with warnings · 0 of 1 takes durable on NAS · 1 take rejected; the job continued"
369 );
370 }
371
372 #[test]
373 fn timeline_mode_migrates_legacy_enabled_values() {
374 assert_eq!(
375 serde_json::from_str::<ShotEntryMode>("true").unwrap(),
376 ShotEntryMode::Forward
377 );
378 assert_eq!(
379 serde_json::from_str::<ShotEntryMode>("false").unwrap(),
380 ShotEntryMode::Excluded
381 );
382 assert_eq!(
383 serde_json::from_str::<ShotEntryMode>(r#""both""#).unwrap(),
384 ShotEntryMode::Both
385 );
386
387 let legacy_shot = r#"{
388 "id":"render-02:moving:Floor Dolly",
389 "streamerId":"render-02",
390 "name":"Floor Dolly",
391 "kind":"moving",
392 "targetName":"Floor Dolly",
393 "translationSpeedCmS":10.0,
394 "rotationSpeedDegS":2.0,
395 "holdDurationMs":5000,
396 "batchMode":"excluded",
397 "sortOrder":0
398 }"#;
399 let shot = serde_json::from_str::<Shot>(legacy_shot).unwrap();
400 assert_eq!(shot.effective_library_id(), "shared");
401 assert_eq!(shot.default_entry_mode, ShotEntryMode::Excluded);
402 assert_eq!(shot.shot_index, 0);
403 let shot_wire = serde_json::to_value(shot).unwrap();
404 assert_eq!(shot_wire["sortOrder"], 0);
405 assert!(shot_wire.get("shotIndex").is_none());
406
407 let legacy_plan = r#"{
408 "shotId":"shot-1",
409 "name":"Floor Dolly",
410 "kind":"moving",
411 "targetName":"Floor Dolly",
412 "translationSpeedCmS":10.0,
413 "rotationSpeedDegS":2.0,
414 "holdDurationMs":5000
415 }"#;
416 let legacy_plan = serde_json::from_str::<ShotEntryPlan>(legacy_plan).unwrap();
417 assert_eq!(legacy_plan.direction, ShotDirection::Forward);
418 assert_eq!(legacy_plan.shot_index, None);
419 }
420
421 #[test]
422 fn legacy_clips_without_overrides_remain_compatible() {
423 let mut timeline = Timeline {
424 id: TimelineId::from(Timeline::legacy_default_id("render-02")),
425 library_id: "shared".to_owned(),
426 streamer_id: "render-02".to_owned(),
427 name: "Client selects".to_owned(),
428 revision: 3,
429 entries: vec![ShotEntry {
430 shot_id: "shot-a".to_owned(),
431 direction: ShotEntryMode::Both,
432 ..Default::default()
433 }],
434 sort_order: 0,
435 };
436
437 timeline.normalize_entries();
438 assert_eq!(timeline.entries.len(), 2);
439 assert_eq!(timeline.capture_count(), 2);
440 assert_eq!(timeline.next_revision(), 4);
441 assert!(timeline
442 .entries
443 .iter()
444 .all(|entry| !entry.entry_id.is_empty()));
445 assert_eq!(timeline.entries[0].direction, ShotEntryMode::Forward);
446 assert_eq!(timeline.entries[1].direction, ShotEntryMode::Reverse);
447
448 timeline.add_entry("shot-b", ShotEntryMode::Forward);
449
450 let first_entry_id = timeline.entries[0].entry_id.clone();
451 timeline.remove_entry(&first_entry_id);
452 assert_eq!(timeline.entries.len(), 2);
453
454 let value = serde_json::to_value(timeline).unwrap();
455 assert_eq!(value["entries"][0]["shotId"], "shot-a");
456 assert!(value["entries"][0].get("translationSpeedCmS").is_none());
457 assert!(value["entries"][0].get("rotationSpeedDegS").is_none());
458 assert!(value.get("clips").is_none());
459 assert!(value["entries"][0].get("cueId").is_none());
460 assert!(value["entries"][0].get("clipId").is_none());
461 assert!(value["entries"][0].get("mode").is_none());
462 }
463
464 #[test]
465 fn the_same_shot_has_independent_parameters_in_each_timeline() {
466 let shot = Shot {
467 id: ShotId::from("shared:shot:moving:hero"),
468 library_id: DEFAULT_SHOT_LIBRARY_ID.to_owned(),
469 streamer_id: String::new(),
470 name: "Hero".to_owned(),
471 kind: ShotKind::Moving,
472 target_name: "Hero".to_owned(),
473 translation_speed_cm_s: 10.0,
474 rotation_speed_deg_s: 2.0,
475 hold_duration_ms: 5_000,
476 travel_duration_ms: 30_000,
477 default_entry_mode: ShotEntryMode::Forward,
478 shot_index: 0,
479 };
480 let mut fast = ShotEntry::from_shot(&shot, ShotEntryMode::Forward);
481 let mut slow = ShotEntry::from_shot(&shot, ShotEntryMode::Forward);
482 fast.translation_speed_cm_s = Some(20.0);
483 fast.travel_duration_ms = Some(15_000);
484 slow.translation_speed_cm_s = Some(5.0);
485 slow.travel_duration_ms = Some(60_000);
486
487 assert_eq!(fast.resolved_shot(&shot).translation_speed_cm_s, 20.0);
488 assert_eq!(slow.resolved_shot(&shot).translation_speed_cm_s, 5.0);
489 assert_eq!(fast.resolved_shot(&shot).travel_duration_ms, 15_000);
490 assert_eq!(slow.resolved_shot(&shot).travel_duration_ms, 60_000);
491 assert_eq!(shot.translation_speed_cm_s, 10.0);
492 }
493
494 #[test]
495 fn timeline_normalizes_membership_without_persisting_a_second_index() {
496 let legacy = r#"{
497 "id":"render-02:timeline:legacy",
498 "streamerId":"render-02",
499 "name":"Legacy",
500 "version":0,
501 "entries":[
502 {"shotId":"shot-b","mode":"forward"},
503 {"shotId":"shot-a","mode":"reverse"}
504 ],
505 "sortOrder":0
506 }"#;
507 let mut timeline: Timeline = serde_json::from_str(legacy).unwrap();
508 timeline.normalize_entries();
509 assert_eq!(timeline.entries[0].shot_id, "shot-b");
510 assert_eq!(timeline.entries[1].shot_id, "shot-a");
511 assert!(serde_json::to_value(timeline).unwrap()["entries"][0]
512 .get("index")
513 .is_none());
514 }
515
516 #[test]
517 fn timeline_migration_accepts_the_myko_transaction_envelope() {
518 serde_json::from_str::<MigrateOtioTimelines>(r#"{"tx":"migration-tx"}"#)
519 .expect("migration command must accept Myko's map-shaped envelope");
520 }
521}
522
523myko::impl_filterable_eq!(
528 crate::recording_plan::RecordingJobAction,
529 crate::recording_plan::RecordingJobPhase,
530 crate::recording_request::RecordingKind,
531 crate::recording_status::RecordingState,
532 crate::shot::ShotKind,
533 crate::timeline::ShotDirection,
534 crate::timeline::ShotEntryMode,
535 crate::frame_capture::FrameCapturePhase,
536 crate::previs_dlss::DlssQuality,
537 crate::previs_dlss::DlssReconstruction,
538);
539
540myko::impl_filterable_opaque!(
541 crate::frame_capture::FrameCaptureTarget,
542 crate::previs_dlss::DlssSettings,
543 crate::previs_dlss::PrevisProcessTarget,
544 crate::previs_dlss_status::DlssConvergence,
545 StoredCaptureContext,
546);
547
548#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, myko::TS)]
560#[serde(transparent)]
561#[ts(type = "unknown")]
562pub struct StoredCaptureContext(pub CaptureContext);
563
564impl From<CaptureContext> for StoredCaptureContext {
565 fn from(context: CaptureContext) -> Self {
566 Self(context)
567 }
568}
569
570impl From<StoredCaptureContext> for CaptureContext {
571 fn from(stored: StoredCaptureContext) -> Self {
572 stored.0
573 }
574}
575
576impl std::ops::Deref for StoredCaptureContext {
577 type Target = CaptureContext;
578
579 fn deref(&self) -> &Self::Target {
580 &self.0
581 }
582}