Skip to main content

pulse_pixelstream_types/
lib.rs

1//! Shared myko entity/command/report types for the pixelstreaming app. WASM-safe:
2//! the Leptos frontend and the native server both build against this crate, and
3//! the TS bindings are generated from it.
4//!
5//! One `#[myko_item]` per module (the macro emits per-module helpers that would
6//! otherwise collide).
7
8pub 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
32/// Shared, validated editorial identity for synchronized nDisplay capture.
33/// Pulse Cluster owns the wire contract; Pixelstream authors and persists it.
34pub 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
62/// Force-link so the `#[myko_item]` / `#[myko_command]` / `#[myko_report]`
63/// inventory registrations are pulled into whichever binary depends on this
64/// crate. Call once at startup.
65pub 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        // The newtype exists so this crate can implement Filterable for an
102        // entity field whose type another crate owns. It must be invisible on
103        // the wire: the prod event store holds ~1.5M events written before it
104        // existed, and a replay that fails here is a data-loss-grade upgrade.
105        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        // And an event written before the newtype still reads back.
117        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        // Shape taken from a persisted RecordingJobStatus event.
124        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        // Round-trips back to the same wire form.
136        let reserialized = serde_json::to_value(&status).unwrap();
137        assert_eq!(
138            reserialized["captureContext"]["collection"],
139            "cycle_4/moment_2"
140        );
141    }
142
143    // Exactly what the server sends over the wire (from a captured query-response).
144    #[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        // Both destinations travel as themselves; the UI used to compose a
202        // Dropbox path out of a file name and show it for an artifact that was
203        // still only on the NAS.
204        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
519// myko 6 requires every queryable field's type to implement `Filterable`.
520// Plain enums get value equality (`Eq`/`In` filters work as an operator would
521// expect); payload structs with no query-meaningful equality take the opaque
522// escape hatch, which renders their query field structurally absent.
523myko::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/// `CaptureContext` as an entity field.
540///
541/// Every field of a `#[myko_item]` needs `Filterable`, and this crate cannot
542/// implement it for `CaptureContext` — pulse-capture-types owns that type and
543/// the orphan rule forbids the impl. A newtype this crate does own can carry
544/// it, and `Unfilterable` is the right filter anyway: frozen editorial
545/// identity is payload, never a query axis.
546///
547/// `#[serde(transparent)]` means the wire form is exactly the bare contract
548/// type, so persisted events and TS bindings are unchanged — this exists for
549/// the type system, not for the format.
550#[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}