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 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
34/// Shared, validated editorial identity for synchronized nDisplay capture.
35/// Pulse Cluster owns the wire contract; Pixelstream authors and persists it.
36pub 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
66/// Force-link so the `#[myko_item]` / `#[myko_command]` / `#[myko_report]`
67/// inventory registrations are pulled into whichever binary depends on this
68/// crate. Call once at startup.
69pub 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        // The newtype exists so this crate can implement Filterable for an
106        // entity field whose type another crate owns. It must be invisible on
107        // the wire: the prod event store holds ~1.5M events written before it
108        // existed, and a replay that fails here is a data-loss-grade upgrade.
109        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        // And an event written before the newtype still reads back.
121        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        // Shape taken from a persisted RecordingJobStatus event.
128        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        // Round-trips back to the same wire form.
140        let reserialized = serde_json::to_value(&status).unwrap();
141        assert_eq!(
142            reserialized["captureContext"]["collection"],
143            "cycle_4/moment_2"
144        );
145    }
146
147    // Exactly what the server sends over the wire (from a captured query-response).
148    #[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        // Both destinations travel as themselves; the UI used to compose a
206        // Dropbox path out of a file name and show it for an artifact that was
207        // still only on the NAS.
208        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
523// myko 6 requires every queryable field's type to implement `Filterable`.
524// Plain enums get value equality (`Eq`/`In` filters work as an operator would
525// expect); payload structs with no query-meaningful equality take the opaque
526// escape hatch, which renders their query field structurally absent.
527myko::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/// `CaptureContext` as an entity field.
549///
550/// Every field of a `#[myko_item]` needs `Filterable`, and this crate cannot
551/// implement it for `CaptureContext` — pulse-capture-types owns that type and
552/// the orphan rule forbids the impl. A newtype this crate does own can carry
553/// it, and `Unfilterable` is the right filter anyway: frozen editorial
554/// identity is payload, never a query axis.
555///
556/// `#[serde(transparent)]` means the wire form is exactly the bare contract
557/// type, so persisted events and TS bindings are unchanged — this exists for
558/// the type system, not for the format.
559#[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}