pulse_pixelstream_types/frame_capture.rs
1use myko::prelude::*;
2use myko::TS;
3use myko_macros::myko_item;
4use serde::{Deserialize, Serialize};
5
6use crate::StoredCaptureContext;
7
8/// Contract version for the synchronized nDisplay frame-capture surface.
9/// Gated in the client exactly like the recording contract: a deployment that
10/// advertises an older version hides the capture action entirely.
11pub const FRAME_CAPTURE_CONTRACT_VERSION: &str = "0.1.0";
12
13/// One operator request for a synchronized nDisplay frame capture.
14///
15/// The browser NEVER talks to Pulse Cluster. This entity is the whole
16/// client-facing surface: the UI writes typed editorial context plus a typed
17/// target selection, and an off-browser bridge (holding the cluster
18/// credential) submits it and mirrors authoritative status back as
19/// [`FrameCaptureStatus`]. Keyed by streamer id — one in-flight capture per
20/// stream, mirroring RecordingJobRequest.
21#[myko_item]
22pub struct FrameCaptureRequest {
23 pub streamer_id: String,
24 /// Stable id for this capture across submit/status/receipt. Client-minted
25 /// (UUIDv7) so retries of the same intent are idempotent at the bridge.
26 pub capture_id: String,
27 /// Makes a re-submitted command idempotent, like RecordingJobRequest.
28 pub command_id: String,
29 /// Frozen editorial identity: typed collection ref, optional label,
30 /// optional content revision. Required — capture output is filed by
31 /// editorial context, and the collection must be explicit, never inferred.
32 #[ts(type = "unknown")]
33 pub capture_context: StoredCaptureContext,
34 /// Typed target selection. The UI picks from the summaries the bridge
35 /// mirrors; it never constructs cluster addresses.
36 pub target: FrameCaptureTarget,
37 /// Explicit operator override. Cluster's force submits despite missing or
38 /// stalled frame-progress telemetry ONLY; exact target identity,
39 /// generation, revision, and capture capability remain required. Never set
40 /// automatically — the operator asks for it after a refusal.
41 #[serde(default)]
42 pub force: bool,
43 #[serde(default)]
44 pub requested_at_ms: u64,
45}
46
47/// Which cluster the capture is fired on, plus the generation the operator
48/// believed was live when they asked. The bridge rejects a mismatch rather
49/// than capturing a different deployment than the one on screen.
50#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
51#[serde(rename_all = "camelCase")]
52pub struct FrameCaptureTarget {
53 pub cluster_name: String,
54 /// Recorded revision/generation the UI displayed at request time. Empty
55 /// means "whatever is live" and the bridge stamps what it actually used.
56 #[serde(default)]
57 pub expected_generation: String,
58}
59
60/// Lifecycle of a capture as reported by the bridge. Deliberately coarse and
61/// UI-facing: the cluster's internal step machine stays behind the bridge.
62#[derive(
63 Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS, PartialOrd, Ord, Hash,
64)]
65#[serde(rename_all = "snake_case")]
66pub enum FrameCapturePhase {
67 #[default]
68 Idle,
69 /// Accepted by the bridge, not yet acknowledged by Cluster.
70 Submitted,
71 /// Cluster is preparing the nodes.
72 Arming,
73 /// The synchronized frame is being taken.
74 Firing,
75 /// Cluster is aggregating per-node artifacts.
76 Aggregating,
77 Complete,
78 Failed,
79}
80
81impl FrameCapturePhase {
82 pub fn is_running(&self) -> bool {
83 matches!(
84 self,
85 Self::Submitted | Self::Arming | Self::Firing | Self::Aggregating
86 )
87 }
88
89 pub fn is_terminal(&self) -> bool {
90 matches!(self, Self::Complete | Self::Failed)
91 }
92}
93
94/// One artifact reference exactly as Cluster reported it. Pixelstream renders
95/// these verbatim and constructs no paths of its own — the capture tree layout
96/// is PulseNode-owned implementation detail.
97#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
98#[serde(rename_all = "camelCase")]
99pub struct FrameCaptureReceipt {
100 /// Opaque reference string from Cluster's typed receipt (path or URI).
101 pub reference: String,
102 /// Optional node/view label for display grouping, as reported.
103 #[serde(default)]
104 pub label: String,
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn running_phases_are_not_terminal() {
113 for phase in [
114 FrameCapturePhase::Submitted,
115 FrameCapturePhase::Arming,
116 FrameCapturePhase::Firing,
117 FrameCapturePhase::Aggregating,
118 ] {
119 assert!(phase.is_running());
120 assert!(!phase.is_terminal());
121 }
122 for phase in [FrameCapturePhase::Complete, FrameCapturePhase::Failed] {
123 assert!(!phase.is_running());
124 assert!(phase.is_terminal());
125 }
126 assert!(!FrameCapturePhase::Idle.is_running());
127 assert!(!FrameCapturePhase::Idle.is_terminal());
128 }
129}