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