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