1use std::path::PathBuf;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::mpsc::{sync_channel, SyncSender, TrySendError};
9use std::sync::Arc;
10use std::thread::JoinHandle;
11
12use visual_cortex_capture::Frame;
13
14pub trait DebugSink: Send + 'static {
20 fn write(&mut self, tick: u64, stage: DebugStage, frame: &Frame);
21
22 fn set_label(&mut self, _label: &str) {}
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum DebugStage {
31 Input,
33 Baseline,
35 Overlay,
37 State,
40 Output,
42}
43
44impl DebugStage {
45 fn suffix(self) -> &'static str {
46 match self {
47 DebugStage::Input => "input",
48 DebugStage::Baseline => "baseline",
49 DebugStage::Overlay => "overlay",
50 DebugStage::State => "state",
51 DebugStage::Output => "output",
52 }
53 }
54}
55
56struct Job {
57 tick: u64,
58 stage: DebugStage,
59 label: String,
60 width: u32,
61 height: u32,
62 data: Vec<u8>,
64}
65
66fn try_dispatch(tx: &SyncSender<Job>, dropped: &AtomicU64, job: Job) {
69 match tx.try_send(job) {
70 Ok(()) => {}
71 Err(TrySendError::Full(_)) => {
72 let n = dropped.fetch_add(1, Ordering::Relaxed) + 1;
73 tracing::debug!(dropped = n, "PngDump lagging; dropping debug frame");
74 }
75 Err(TrySendError::Disconnected(_)) => {}
76 }
77}
78
79pub struct PngDump {
85 tx: Option<SyncSender<Job>>,
86 writer: Option<JoinHandle<()>>,
87 dropped: Arc<AtomicU64>,
88 sample_every: u64,
89 label: String,
90}
91
92impl PngDump {
93 pub fn dir(dir: impl Into<PathBuf>) -> Self {
95 let dir = dir.into();
96 let (tx, rx) = sync_channel::<Job>(8);
97 let writer = std::thread::spawn(move || {
98 for job in rx {
99 let sub = dir.join(&job.label);
100 if let Err(e) = std::fs::create_dir_all(&sub) {
101 tracing::warn!(dir = %sub.display(), error = %e, "PngDump mkdir failed");
102 continue;
103 }
104 let path = sub.join(format!("{:06}-{}.png", job.tick, job.stage.suffix()));
105 let mut rgba = job.data;
107 for px in rgba.chunks_exact_mut(4) {
108 px.swap(0, 2);
109 }
110 match image::RgbaImage::from_raw(job.width, job.height, rgba) {
111 Some(img) => {
112 if let Err(e) = img.save(&path) {
113 tracing::warn!(path = %path.display(), error = %e, "PngDump write failed");
114 }
115 }
116 None => tracing::warn!("PngDump: frame buffer size mismatch"),
117 }
118 }
119 });
120 Self {
121 tx: Some(tx),
122 writer: Some(writer),
123 dropped: Arc::default(),
124 sample_every: 1,
125 label: "preprocessor".into(),
126 }
127 }
128
129 pub fn sample_every(mut self, n: u64) -> Self {
131 self.sample_every = n.max(1);
132 self
133 }
134
135 pub fn dropped(&self) -> u64 {
137 self.dropped.load(Ordering::Relaxed)
138 }
139}
140
141impl DebugSink for PngDump {
142 fn write(&mut self, tick: u64, stage: DebugStage, frame: &Frame) {
143 if !tick.is_multiple_of(self.sample_every) {
144 return;
145 }
146 let Some(tx) = &self.tx else { return };
147 try_dispatch(
148 tx,
149 &self.dropped,
150 Job {
151 tick,
152 stage,
153 label: self.label.clone(),
154 width: frame.width(),
155 height: frame.height(),
156 data: frame.data().to_vec(),
157 },
158 );
159 }
160
161 fn set_label(&mut self, label: &str) {
162 self.label = label.to_string();
163 }
164}
165
166impl Drop for PngDump {
167 fn drop(&mut self) {
168 drop(self.tx.take()); if let Some(w) = self.writer.take() {
170 let _ = w.join();
171 }
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn full_channel_drops_and_counts_instead_of_blocking() {
181 let (tx, _rx) = sync_channel::<Job>(2); let dropped = AtomicU64::new(0);
183 for tick in 0..5 {
184 try_dispatch(
185 &tx,
186 &dropped,
187 Job {
188 tick,
189 stage: DebugStage::Output,
190 label: "w".into(),
191 width: 1,
192 height: 1,
193 data: vec![0, 0, 0, 255],
194 },
195 );
196 }
197 assert_eq!(dropped.load(Ordering::Relaxed), 3, "2 buffered, 3 dropped");
198 }
199
200 #[test]
201 fn writes_sampled_stills_under_the_watcher_label() {
202 let tmp = tempfile::tempdir().unwrap();
203 let mut dump = PngDump::dir(tmp.path()).sample_every(2);
204 dump.set_label("tooltip");
205
206 let frame = Frame::solid(2, 2, [10, 20, 30, 255]); for tick in 0..3 {
208 dump.write(tick, DebugStage::Output, &frame);
209 }
210 drop(dump); let dir = tmp.path().join("tooltip");
213 assert!(dir.join("000000-output.png").exists());
214 assert!(
215 !dir.join("000001-output.png").exists(),
216 "sample_every(2) must skip odd ticks"
217 );
218 assert!(dir.join("000002-output.png").exists());
219
220 let img = image::open(dir.join("000000-output.png"))
222 .unwrap()
223 .to_rgba8();
224 assert_eq!(img.get_pixel(0, 0).0, [30, 20, 10, 255]);
225 }
226}