1use std::fs::File;
2use std::io::BufWriter;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU32, Ordering};
5use std::sync::mpsc::{sync_channel, Receiver, SyncSender, TrySendError};
6use std::sync::Arc;
7use std::thread::{self, JoinHandle};
8use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9
10use anyhow::{anyhow, Context as _, Result};
11use wgpu::{
12 Buffer, BufferDescriptor, BufferUsages, CommandEncoder, Device, Extent3d, MapMode, Origin3d,
13 PollType, TexelCopyBufferInfo, TexelCopyBufferLayout, TexelCopyTextureInfo, Texture,
14 TextureAspect, TextureFormat,
15};
16
17pub use crate::capture_types::{
18 CaptureFormat, CaptureRequest, CaptureStage, CaptureUnavailable, PaletteMode,
19};
20
21impl CaptureStage {
22 fn wants_pre(self) -> bool {
23 matches!(self, CaptureStage::Pre | CaptureStage::Both)
24 }
25 fn wants_post(self) -> bool {
26 matches!(self, CaptureStage::Post | CaptureStage::Both)
27 }
28}
29
30impl CaptureFormat {
31 fn default_fps(self) -> Option<u16> {
32 match self {
33 CaptureFormat::Png => None,
34 CaptureFormat::Gif | CaptureFormat::Apng => Some(30),
35 }
36 }
37
38 fn supports_both_stages(self) -> bool {
39 matches!(self, CaptureFormat::Png)
40 }
41}
42
43const FPS_SLACK_DIVISOR: u32 = 8;
44
45fn interval_elapsed(since_last: Duration, interval: Duration) -> bool {
46 since_last + interval / FPS_SLACK_DIVISOR >= interval
47}
48
49pub(crate) struct Capture {
50 default_dir: PathBuf,
51 state: CaptureState,
52 pending: Vec<JoinHandle<()>>,
53}
54
55enum CaptureState {
56 Idle,
57 OneShot {
58 path_pre: Option<PathBuf>,
59 path_post: Option<PathBuf>,
60 },
61 Sequence {
62 stage: CaptureStage,
63 writer: SequenceWriter,
64 fps_interval: Option<Duration>,
65 last_capture_time: Option<Instant>,
66 frame_count: u32,
67 },
68}
69
70enum SequenceWriter {
71 Png {
72 dir: PathBuf,
73 },
74 Gif {
75 worker: GifWorker,
76 path: PathBuf,
77 default_delay_cs: u16,
78 scale: Option<u32>,
79 palette_mode: PaletteMode,
80 warming: Option<WarmingState>,
81 global_palette: Option<Arc<color_quant::NeuQuant>>,
82 },
83 Apng {
84 worker: ApngWorker,
85 path: PathBuf,
86 scale: Option<u32>,
87 },
88}
89
90struct WarmingState {
91 buffer: Vec<WarmupFrame>,
92 target_frames: u32,
93}
94
95struct WarmupFrame {
96 rgba: Vec<u8>,
97 width: u32,
98 height: u32,
99 captured_at: Instant,
100}
101
102const GIF_WARMUP_FRAMES: u32 = 30;
104
105pub(crate) struct GifWorker {
106 tx: Option<SyncSender<GifFrame>>,
107 handle: Option<JoinHandle<()>>,
108 dropped: Arc<AtomicU32>,
109}
110
111struct GifFrame {
112 rgba: Vec<u8>,
113 src_width: u32,
114 src_height: u32,
115 captured_at: Instant,
116 default_delay_cs: u16,
117 scale: Option<u32>,
118 global_palette: Option<Arc<color_quant::NeuQuant>>,
119}
120
121const GIF_CHANNEL_CAPACITY: usize = 8;
123
124impl GifWorker {
125 fn spawn(path: PathBuf) -> Result<Self> {
126 let (tx, rx) = sync_channel::<GifFrame>(GIF_CHANNEL_CAPACITY);
127 let dropped = Arc::new(AtomicU32::new(0));
128 let handle = thread::Builder::new()
129 .name("loam-app::gif-encoder".into())
130 .spawn(move || gif_encoder_loop(path, rx))
131 .context("spawn gif encoder thread")?;
132 Ok(Self {
133 tx: Some(tx),
134 handle: Some(handle),
135 dropped,
136 })
137 }
138
139 fn detach(mut self) -> JoinHandle<()> {
140 self.tx.take();
141 self.handle.take().expect("worker handle present")
142 }
143
144 fn try_send(&self, frame: GifFrame) {
145 let Some(tx) = self.tx.as_ref() else { return };
146 match tx.try_send(frame) {
147 Ok(()) => {}
148 Err(TrySendError::Full(_)) => {
149 let count = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
150 if count.is_power_of_two() {
151 tracing::warn!(
152 "capture: GIF encoder queue full; dropped {count} frame(s) so far"
153 );
154 }
155 }
156 Err(TrySendError::Disconnected(_)) => {
157 tracing::error!("capture: GIF encoder thread exited unexpectedly");
158 }
159 }
160 }
161
162 fn dropped(&self) -> u32 {
163 self.dropped.load(Ordering::Relaxed)
164 }
165}
166
167impl Drop for GifWorker {
168 fn drop(&mut self) {
169 self.tx.take();
171 if let Some(h) = self.handle.take() {
172 let _ = h.join();
173 }
174 }
175}
176
177fn gif_encoder_loop(path: PathBuf, rx: Receiver<GifFrame>) {
178 let mut encoder: Option<gif::Encoder<BufWriter<File>>> = None;
179 let mut last_captured_at: Option<Instant> = None;
180 for frame in rx {
181 if let Err(e) = encode_one_frame(&path, &mut encoder, &mut last_captured_at, frame) {
182 tracing::error!("capture: gif encode error: {e:#}");
183 return;
184 }
185 }
186 drop(encoder);
187 tracing::info!("capture: gif file finalised at {}", path.display());
188}
189
190fn encode_one_frame(
191 path: &Path,
192 encoder: &mut Option<gif::Encoder<BufWriter<File>>>,
193 last_captured_at: &mut Option<Instant>,
194 frame: GifFrame,
195) -> Result<()> {
196 let (out_w, out_h) = scaled_dims(frame.src_width, frame.src_height, frame.scale)?;
197 let w_u16: u16 = out_w.try_into().context("gif width > 65535")?;
198 let h_u16: u16 = out_h.try_into().context("gif height > 65535")?;
199
200 let enc = match encoder {
201 Some(e) => e,
202 None => {
203 let file = File::create(path)
204 .with_context(|| format!("create gif output {}", path.display()))?;
205 let global_palette_bytes: Vec<u8> = frame
206 .global_palette
207 .as_ref()
208 .map(|nq| nq.color_map_rgb())
209 .unwrap_or_default();
210 let mut e =
211 gif::Encoder::new(BufWriter::new(file), w_u16, h_u16, &global_palette_bytes)
212 .context("init gif encoder")?;
213 e.set_repeat(gif::Repeat::Infinite).context("gif repeat")?;
214 tracing::info!(
215 "capture: gif encoder opened {}x{} target {}cs/frame ({} fps); \
216 palette={}; actual delays computed from wall-clock per-frame",
217 w_u16,
218 h_u16,
219 frame.default_delay_cs,
220 if frame.default_delay_cs == 0 {
221 0
222 } else {
223 100 / frame.default_delay_cs as u32
224 },
225 if frame.global_palette.is_some() {
226 "global (shared NeuQuant)"
227 } else {
228 "per-frame"
229 }
230 );
231 encoder.insert(e)
232 }
233 };
234
235 let delay_cs = match *last_captured_at {
236 None => frame.default_delay_cs,
237 Some(prev) => {
238 let ms = frame.captured_at.duration_since(prev).as_millis() as u64;
239 let cs = (ms + 5) / 10;
240 cs.clamp(1, u16::MAX as u64) as u16
241 }
242 };
243 *last_captured_at = Some(frame.captured_at);
244
245 let mut buf: Vec<u8> = if frame.scale.is_some() {
246 let src: ::image::RgbaImage =
247 ::image::ImageBuffer::from_raw(frame.src_width, frame.src_height, frame.rgba)
248 .ok_or_else(|| {
249 anyhow!(
250 "RGBA size mismatch at {}x{}",
251 frame.src_width,
252 frame.src_height
253 )
254 })?;
255 let dst =
256 ::image::imageops::resize(&src, out_w, out_h, ::image::imageops::FilterType::Lanczos3);
257 dst.into_raw()
258 } else {
259 frame.rgba
260 };
261
262 let mut gif_frame = if let Some(nq) = &frame.global_palette {
263 for px in buf.chunks_exact_mut(4) {
264 if px[3] != 0 {
265 px[3] = 0xFF;
266 }
267 }
268 let mut indices = Vec::with_capacity((out_w as usize) * (out_h as usize));
269 for px in buf.chunks_exact(4) {
270 indices.push(nq.index_of(px) as u8);
271 }
272 gif::Frame {
273 width: w_u16,
274 height: h_u16,
275 buffer: std::borrow::Cow::Owned(indices),
276 palette: None,
277 ..gif::Frame::default()
278 }
279 } else {
280 gif::Frame::from_rgba_speed(w_u16, h_u16, &mut buf, 10)
282 };
283 gif_frame.delay = delay_cs;
284 gif_frame.dispose = gif::DisposalMethod::Any;
286 enc.write_frame(&gif_frame).context("gif encode")?;
287 Ok(())
288}
289
290pub(crate) struct ApngWorker {
291 tx: Option<SyncSender<ApngFrame>>,
292 handle: Option<JoinHandle<()>>,
293 frame_count: Arc<AtomicU32>,
294}
295
296struct ApngFrame {
297 rgba: Vec<u8>,
298 src_width: u32,
299 src_height: u32,
300 captured_at: Instant,
301 scale: Option<u32>,
302}
303
304const APNG_CHANNEL_CAPACITY: usize = 16;
306
307impl ApngWorker {
308 fn spawn(path: PathBuf) -> Result<Self> {
309 let (tx, rx) = sync_channel::<ApngFrame>(APNG_CHANNEL_CAPACITY);
310 let frame_count = Arc::new(AtomicU32::new(0));
311 let frame_count_for_worker = frame_count.clone();
312 let handle = thread::Builder::new()
313 .name("loam-app::apng-encoder".into())
314 .spawn(move || apng_encoder_loop(path, rx, frame_count_for_worker))
315 .context("spawn apng encoder thread")?;
316 Ok(Self {
317 tx: Some(tx),
318 handle: Some(handle),
319 frame_count,
320 })
321 }
322
323 fn try_send(&self, frame: ApngFrame) {
324 let Some(tx) = self.tx.as_ref() else { return };
325 if let Err(TrySendError::Disconnected(_)) = tx.try_send(frame) {
326 tracing::error!("capture: apng encoder thread exited unexpectedly");
327 }
328 }
329
330 fn frame_count(&self) -> u32 {
331 self.frame_count.load(Ordering::Relaxed)
332 }
333
334 fn detach(mut self) -> JoinHandle<()> {
335 self.tx.take();
336 self.handle.take().expect("worker handle present")
337 }
338}
339
340impl Drop for ApngWorker {
341 fn drop(&mut self) {
342 self.tx.take();
343 if let Some(h) = self.handle.take() {
344 let _ = h.join();
345 }
346 }
347}
348
349fn apng_encoder_loop(path: PathBuf, rx: Receiver<ApngFrame>, frame_count: Arc<AtomicU32>) {
350 let mut frames: Vec<ApngFrame> = Vec::new();
351 for frame in rx {
352 frames.push(frame);
353 frame_count.store(frames.len() as u32, Ordering::Relaxed);
354 }
355 if frames.is_empty() {
356 tracing::info!("capture: apng stopped before any frames captured; no file written");
357 return;
358 }
359 if let Err(e) = write_apng(&path, frames) {
360 tracing::error!("capture: apng write failed: {e:#}");
361 return;
362 }
363 tracing::info!("capture: apng file finalised at {}", path.display());
364}
365
366fn write_apng(path: &Path, frames: Vec<ApngFrame>) -> Result<()> {
367 let (out_w, out_h) = scaled_dims(frames[0].src_width, frames[0].src_height, frames[0].scale)?;
368 let file =
369 File::create(path).with_context(|| format!("create apng output {}", path.display()))?;
370 let mut encoder = png::Encoder::new(BufWriter::new(file), out_w, out_h);
371 encoder.set_color(png::ColorType::Rgba);
372 encoder.set_depth(png::BitDepth::Eight);
373 encoder
375 .set_animated(frames.len() as u32, 0)
376 .context("apng set_animated")?;
377 let mut writer = encoder.write_header().context("apng write_header")?;
378
379 let mut last_captured_at: Option<Instant> = None;
380 for frame in frames {
381 let (fw, fh) = scaled_dims(frame.src_width, frame.src_height, frame.scale)?;
382 if fw != out_w || fh != out_h {
383 return Err(anyhow!(
384 "apng frame dims {fw}x{fh} != first frame {out_w}x{out_h}"
385 ));
386 }
387 let rgba = if frame.scale.is_some() {
388 let src: ::image::RgbaImage =
389 ::image::ImageBuffer::from_raw(frame.src_width, frame.src_height, frame.rgba)
390 .ok_or_else(|| {
391 anyhow!(
392 "RGBA size mismatch at {}x{}",
393 frame.src_width,
394 frame.src_height
395 )
396 })?;
397 ::image::imageops::resize(&src, out_w, out_h, ::image::imageops::FilterType::Lanczos3)
398 .into_raw()
399 } else {
400 frame.rgba
401 };
402
403 let delay_ms = match last_captured_at {
405 None => 33u16,
406 Some(prev) => {
407 let ms = frame.captured_at.duration_since(prev).as_millis();
408 ms.min(u16::MAX as u128).max(1) as u16
409 }
410 };
411 last_captured_at = Some(frame.captured_at);
412
413 writer
414 .set_frame_delay(delay_ms, 1000)
415 .context("apng set_frame_delay")?;
416 writer
417 .write_image_data(&rgba)
418 .context("apng write_image_data")?;
419 }
420 writer.finish().context("apng finish")?;
421 Ok(())
422}
423
424impl Capture {
425 pub(crate) fn new() -> Self {
426 Self {
427 default_dir: PathBuf::from("captures"),
428 state: CaptureState::Idle,
429 pending: Vec::new(),
430 }
431 }
432
433 fn reap_finished(&mut self) {
434 let mut still_running = Vec::with_capacity(self.pending.len());
435 for h in self.pending.drain(..) {
436 if h.is_finished() {
437 let _ = h.join();
438 } else {
439 still_running.push(h);
440 }
441 }
442 self.pending = still_running;
443 }
444
445 pub(crate) fn apply_requests(&mut self, requests: Vec<CaptureRequest>) -> Vec<String> {
446 let mut log = Vec::new();
447 for req in requests {
448 match req {
449 CaptureRequest::OneShot { stage, dir, name } => {
450 let dir = dir.unwrap_or_else(|| self.default_dir.clone());
451 let name = name.unwrap_or_else(default_name);
452 let path_pre = stage
453 .wants_pre()
454 .then(|| dir.join(format!("{name}_pre.png")));
455 let path_post = stage
456 .wants_post()
457 .then(|| dir.join(format!("{name}_post.png")));
458 self.state = CaptureState::OneShot {
459 path_pre,
460 path_post,
461 };
462 log.push(format!("capture: one-shot queued ({stage:?})"));
463 }
464 CaptureRequest::StartSequence {
465 format,
466 stage,
467 dir,
468 name,
469 fps,
470 scale,
471 palette,
472 } => match self.start_sequence(format, stage, dir, name, fps, scale, palette) {
473 Ok(msg) => log.push(msg),
474 Err(e) => log.push(format!("capture: failed to start sequence: {e:#}")),
475 },
476 CaptureRequest::Stop => self.stop(&mut log),
477 CaptureRequest::Toggle {
478 format,
479 stage,
480 dir,
481 name,
482 fps,
483 scale,
484 palette,
485 } => {
486 if matches!(self.state, CaptureState::Sequence { .. }) {
487 self.stop(&mut log);
488 } else {
489 match self.start_sequence(format, stage, dir, name, fps, scale, palette) {
490 Ok(msg) => log.push(msg),
491 Err(e) => log.push(format!("capture: failed to start sequence: {e:#}")),
492 }
493 }
494 }
495 }
496 }
497 log
498 }
499
500 #[allow(clippy::too_many_arguments)]
501 fn start_sequence(
502 &mut self,
503 format: CaptureFormat,
504 mut stage: CaptureStage,
505 dir: Option<PathBuf>,
506 name: Option<String>,
507 fps: Option<u16>,
508 scale: Option<u32>,
509 palette: PaletteMode,
510 ) -> Result<String> {
511 let dir = dir.unwrap_or_else(|| self.default_dir.clone());
512 let name = name.unwrap_or_else(default_name);
513 let fps = fps.or_else(|| format.default_fps());
514
515 if !format.supports_both_stages() && stage == CaptureStage::Both {
516 stage = CaptureStage::Post;
517 }
518
519 let writer = match format {
520 CaptureFormat::Png => {
521 let dir = dir.join(&name);
522 std::fs::create_dir_all(&dir)
523 .with_context(|| format!("create png sequence dir {}", dir.display()))?;
524 SequenceWriter::Png { dir }
525 }
526 CaptureFormat::Gif => {
527 let path = dir.join(format!("{name}.gif"));
528 if let Some(parent) = path.parent() {
529 std::fs::create_dir_all(parent)
530 .with_context(|| format!("create gif parent dir {}", parent.display()))?;
531 }
532 let default_delay_cs = fps_to_centiseconds(fps.unwrap_or(30));
533 let worker = GifWorker::spawn(path.clone())?;
534 let warming = match palette {
535 PaletteMode::Local => None,
536 PaletteMode::Global => Some(WarmingState {
537 buffer: Vec::with_capacity(GIF_WARMUP_FRAMES as usize),
538 target_frames: GIF_WARMUP_FRAMES,
539 }),
540 };
541 SequenceWriter::Gif {
542 worker,
543 path,
544 default_delay_cs,
545 scale,
546 palette_mode: palette,
547 warming,
548 global_palette: None,
549 }
550 }
551 CaptureFormat::Apng => {
552 let path = dir.join(format!("{name}.apng"));
553 if let Some(parent) = path.parent() {
554 std::fs::create_dir_all(parent)
555 .with_context(|| format!("create apng parent dir {}", parent.display()))?;
556 }
557 let worker = ApngWorker::spawn(path.clone())?;
558 SequenceWriter::Apng {
559 worker,
560 path,
561 scale,
562 }
563 }
564 };
565
566 let fps_interval = fps.map(|f| Duration::from_secs_f64(1.0 / f.max(1) as f64));
567 self.state = CaptureState::Sequence {
568 stage,
569 writer,
570 fps_interval,
571 last_capture_time: None,
572 frame_count: 0,
573 };
574 Ok(format!(
575 "capture: sequence started ({format:?}, {stage:?}, fps={}, palette={palette:?})",
576 fps.map(|f| f.to_string())
577 .unwrap_or_else(|| "unlimited".into())
578 ))
579 }
580
581 fn stop(&mut self, log: &mut Vec<String>) {
582 self.reap_finished();
583 let state = std::mem::replace(&mut self.state, CaptureState::Idle);
584 match state {
585 CaptureState::Sequence {
586 writer,
587 frame_count,
588 ..
589 } => match writer {
590 SequenceWriter::Png { dir } => {
591 log.push(format!(
592 "capture: PNG sequence stopped, {frame_count} frame(s) at {}",
593 dir.display()
594 ));
595 }
596 SequenceWriter::Gif {
597 worker,
598 path,
599 default_delay_cs,
600 scale,
601 warming,
602 ..
603 } => {
604 if let Some(mut w) = warming {
605 if !w.buffer.is_empty() {
606 tracing::info!(
607 "capture: stopped during warmup ({} frame(s)); \
608 training palette on partial buffer",
609 w.buffer.len()
610 );
611 let nq = Arc::new(train_global_palette(&w.buffer));
612 for f in w.buffer.drain(..) {
613 worker.try_send(GifFrame {
614 rgba: f.rgba,
615 src_width: f.width,
616 src_height: f.height,
617 captured_at: f.captured_at,
618 default_delay_cs,
619 scale,
620 global_palette: Some(nq.clone()),
621 });
622 }
623 }
624 }
625 let dropped = worker.dropped();
626 let handle = worker.detach();
627 self.pending.push(handle);
628 let drop_note = if dropped > 0 {
629 format!(", {dropped} dropped under backpressure")
630 } else {
631 String::new()
632 };
633 log.push(format!(
634 "capture: GIF stream stopped, {frame_count} frame(s){drop_note} \
635 encoding in background -> {}",
636 path.display()
637 ));
638 }
639 SequenceWriter::Apng { worker, path, .. } => {
640 let buffered = worker.frame_count();
641 let handle = worker.detach();
642 self.pending.push(handle);
643 log.push(format!(
644 "capture: APNG stream stopped, {buffered} frame(s) buffered; \
645 assembling and writing in background -> {}",
646 path.display()
647 ));
648 }
649 },
650 CaptureState::OneShot { .. } => {
651 log.push("capture: pending one-shot cancelled".into());
652 }
653 CaptureState::Idle => {
654 log.push("capture: stop with no active session (no-op)".into());
655 }
656 }
657 }
658
659 pub(crate) fn wants_pre(&self) -> bool {
660 match &self.state {
661 CaptureState::Idle => false,
662 CaptureState::OneShot { path_pre, .. } => path_pre.is_some(),
663 CaptureState::Sequence { stage, .. } => stage.wants_pre(),
664 }
665 }
666
667 pub(crate) fn wants_post(&self) -> bool {
668 match &self.state {
669 CaptureState::Idle => false,
670 CaptureState::OneShot { path_post, .. } => path_post.is_some(),
671 CaptureState::Sequence { stage, .. } => stage.wants_post(),
672 }
673 }
674
675 pub(crate) fn should_capture(&self, now: Instant) -> bool {
676 match &self.state {
677 CaptureState::Idle => false,
678 CaptureState::OneShot { .. } => true,
679 CaptureState::Sequence {
680 fps_interval,
681 last_capture_time,
682 ..
683 } => match (fps_interval, last_capture_time) {
684 (None, _) => true,
685 (Some(_), None) => true,
686 (Some(interval), Some(last)) => {
687 interval_elapsed(now.duration_since(*last), *interval)
688 }
689 },
690 }
691 }
692
693 pub(crate) fn consume_frame(
694 &mut self,
695 is_pre: bool,
696 rgba: Vec<u8>,
697 width: u32,
698 height: u32,
699 captured_at: Instant,
700 ) -> Result<()> {
701 match &mut self.state {
702 CaptureState::Idle => Ok(()),
703 CaptureState::OneShot {
704 path_pre,
705 path_post,
706 } => {
707 let path = if is_pre {
708 path_pre.take()
709 } else {
710 path_post.take()
711 };
712 if let Some(path) = path {
713 write_png_bytes(&path, &rgba, width, height)?;
714 tracing::info!("capture: wrote {}", path.display());
715 }
716 Ok(())
717 }
718 CaptureState::Sequence {
719 writer,
720 frame_count,
721 ..
722 } => writer.write_frame(is_pre, *frame_count, rgba, width, height, captured_at),
723 }
724 }
725
726 fn join_pending(&mut self) {
727 for h in self.pending.drain(..) {
728 let _ = h.join();
729 }
730 }
731
732 pub(crate) fn advance_frame(&mut self, now: Instant) {
733 match &mut self.state {
734 CaptureState::OneShot {
735 path_pre,
736 path_post,
737 } => {
738 if path_pre.is_none() && path_post.is_none() {
739 self.state = CaptureState::Idle;
740 }
741 }
742 CaptureState::Sequence {
743 last_capture_time,
744 frame_count,
745 ..
746 } => {
747 *last_capture_time = Some(now);
748 *frame_count = frame_count.saturating_add(1);
749 }
750 CaptureState::Idle => {}
751 }
752 }
753}
754
755impl Drop for Capture {
756 fn drop(&mut self) {
757 self.stop(&mut Vec::new());
758 self.join_pending();
759 }
760}
761
762impl SequenceWriter {
763 fn write_frame(
764 &mut self,
765 is_pre: bool,
766 frame_idx: u32,
767 rgba: Vec<u8>,
768 width: u32,
769 height: u32,
770 captured_at: Instant,
771 ) -> Result<()> {
772 match self {
773 SequenceWriter::Png { dir } => {
774 let label = if is_pre { "pre" } else { "post" };
775 let path = dir.join(format!("{label}_{frame_idx:06}.png"));
776 write_png_bytes(&path, &rgba, width, height)?;
777 Ok(())
778 }
779 SequenceWriter::Gif {
780 worker,
781 default_delay_cs,
782 scale,
783 palette_mode,
784 warming,
785 global_palette,
786 ..
787 } => {
788 match *palette_mode {
789 PaletteMode::Local => {
790 worker.try_send(GifFrame {
791 rgba,
792 src_width: width,
793 src_height: height,
794 captured_at,
795 default_delay_cs: *default_delay_cs,
796 scale: *scale,
797 global_palette: None,
798 });
799 }
800 PaletteMode::Global => {
801 if let Some(w) = warming.as_mut() {
802 w.buffer.push(WarmupFrame {
803 rgba,
804 width,
805 height,
806 captured_at,
807 });
808 if w.buffer.len() as u32 >= w.target_frames {
809 let nq = Arc::new(train_global_palette(&w.buffer));
810 tracing::info!(
811 "capture: gif global palette trained from {} frames",
812 w.buffer.len()
813 );
814 for f in w.buffer.drain(..) {
815 worker.try_send(GifFrame {
816 rgba: f.rgba,
817 src_width: f.width,
818 src_height: f.height,
819 captured_at: f.captured_at,
820 default_delay_cs: *default_delay_cs,
821 scale: *scale,
822 global_palette: Some(nq.clone()),
823 });
824 }
825 *global_palette = Some(nq);
826 *warming = None;
827 }
828 } else if let Some(nq) = global_palette.as_ref() {
829 worker.try_send(GifFrame {
830 rgba,
831 src_width: width,
832 src_height: height,
833 captured_at,
834 default_delay_cs: *default_delay_cs,
835 scale: *scale,
836 global_palette: Some(nq.clone()),
837 });
838 } else {
839 tracing::error!(
840 "capture: gif global mode lost palette state (frame dropped)"
841 );
842 }
843 }
844 }
845 Ok(())
846 }
847 SequenceWriter::Apng { worker, scale, .. } => {
848 worker.try_send(ApngFrame {
849 rgba,
850 src_width: width,
851 src_height: height,
852 captured_at,
853 scale: *scale,
854 });
855 Ok(())
856 }
857 }
858 }
859}
860
861fn train_global_palette(buffer: &[WarmupFrame]) -> color_quant::NeuQuant {
862 const STRIDE: usize = 16;
864 let mut samples: Vec<u8> = Vec::new();
865 for frame in buffer {
866 for px in frame.rgba.chunks_exact(4).step_by(STRIDE) {
867 samples.push(px[0]);
869 samples.push(px[1]);
870 samples.push(px[2]);
871 samples.push(if px[3] != 0 { 0xFF } else { 0 });
872 }
873 }
874 color_quant::NeuQuant::new(10, 256, &samples)
875}
876
877fn scaled_dims(width: u32, height: u32, scale: Option<u32>) -> Result<(u32, u32)> {
878 let Some(target_w) = scale else {
879 return Ok((width, height));
880 };
881 if target_w == 0 || width == 0 || height == 0 {
882 return Err(anyhow!(
883 "invalid scale: target_w={target_w}, src={width}x{height}"
884 ));
885 }
886 let h = ((target_w as u64 * height as u64 + (width as u64) / 2) / width as u64) as u32;
887 Ok((target_w, h.max(1)))
888}
889
890fn fps_to_centiseconds(fps: u16) -> u16 {
891 let cs = (100.0_f32 / fps.max(1) as f32).round() as u16;
893 cs.max(1)
894}
895
896fn default_name() -> String {
897 let unix = SystemTime::now()
898 .duration_since(UNIX_EPOCH)
899 .map(|d| d.as_secs())
900 .unwrap_or(0);
901 format!("capture_{unix}")
902}
903
904pub(crate) struct RawImage {
906 pub width: u32,
907 pub height: u32,
908 pub rgba: Vec<u8>,
909}
910
911pub(crate) struct TextureReadback {
912 buffer: Buffer,
913 width: u32,
914 height: u32,
915 unpadded_bpr: u32,
916 padded_bpr: u32,
917 format: TextureFormat,
918}
919
920pub(crate) fn record_texture_rgba(
921 device: &Device,
922 encoder: &mut CommandEncoder,
923 texture: &Texture,
924 width: u32,
925 height: u32,
926 format: TextureFormat,
927) -> Result<TextureReadback> {
928 let unpadded_bpr = width.checked_mul(4).context("width * 4 overflows u32")?;
929 let padded_bpr = unpadded_bpr.next_multiple_of(256);
930 let buffer_size = (padded_bpr as u64) * (height as u64);
931
932 let buffer = device.create_buffer(&BufferDescriptor {
933 label: Some("loam-app::capture-staging"),
934 size: buffer_size,
935 usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ,
936 mapped_at_creation: false,
937 });
938
939 encoder.copy_texture_to_buffer(
940 TexelCopyTextureInfo {
941 texture,
942 mip_level: 0,
943 origin: Origin3d::ZERO,
944 aspect: TextureAspect::All,
945 },
946 TexelCopyBufferInfo {
947 buffer: &buffer,
948 layout: TexelCopyBufferLayout {
949 offset: 0,
950 bytes_per_row: Some(padded_bpr),
951 rows_per_image: None,
952 },
953 },
954 Extent3d {
955 width,
956 height,
957 depth_or_array_layers: 1,
958 },
959 );
960 Ok(TextureReadback {
961 buffer,
962 width,
963 height,
964 unpadded_bpr,
965 padded_bpr,
966 format,
967 })
968}
969
970impl TextureReadback {
971 pub(crate) fn read(self, device: &Device) -> Result<RawImage> {
972 let Self {
973 buffer,
974 width,
975 height,
976 unpadded_bpr,
977 padded_bpr,
978 format,
979 } = self;
980
981 let slice = buffer.slice(..);
982 let (mapped_tx, mapped_rx) = std::sync::mpsc::channel();
983 slice.map_async(MapMode::Read, move |result| {
984 let _ = mapped_tx.send(result);
985 });
986 device
987 .poll(PollType::Wait {
988 submission_index: None,
989 timeout: None,
990 })
991 .context("device.poll on capture readback failed")?;
992
993 mapped_rx
994 .recv()
995 .context("capture map callback dropped")?
996 .context("capture buffer mapping failed")?;
997 let data = slice.get_mapped_range();
998 let mut rgba = Vec::with_capacity((unpadded_bpr * height) as usize);
999 for row in 0..height as usize {
1000 let start = row * padded_bpr as usize;
1001 let end = start + unpadded_bpr as usize;
1002 rgba.extend_from_slice(&data[start..end]);
1003 }
1004 drop(data);
1005 buffer.unmap();
1006
1007 if format_is_bgra(format) {
1008 for px in rgba.chunks_exact_mut(4) {
1009 px.swap(0, 2);
1010 }
1011 }
1012
1013 Ok(RawImage {
1014 width,
1015 height,
1016 rgba,
1017 })
1018 }
1019}
1020
1021fn format_is_bgra(format: TextureFormat) -> bool {
1022 matches!(
1023 format,
1024 TextureFormat::Bgra8Unorm | TextureFormat::Bgra8UnormSrgb
1025 )
1026}
1027
1028fn write_png_bytes(path: &Path, rgba: &[u8], width: u32, height: u32) -> Result<()> {
1029 if let Some(parent) = path.parent() {
1030 std::fs::create_dir_all(parent)
1031 .with_context(|| format!("create capture dir {}", parent.display()))?;
1032 }
1033 ::image::save_buffer_with_format(
1034 path,
1035 rgba,
1036 width,
1037 height,
1038 ::image::ColorType::Rgba8,
1039 ::image::ImageFormat::Png,
1040 )
1041 .with_context(|| format!("write png {}", path.display()))?;
1042 Ok(())
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048
1049 #[test]
1050 fn a_frame_a_hair_early_is_taken_and_one_a_half_period_early_is_not() {
1051 let interval = Duration::from_micros(33_333);
1052 assert!(interval_elapsed(interval, interval));
1053 assert!(interval_elapsed(
1054 interval - Duration::from_micros(200),
1055 interval
1056 ));
1057 assert!(interval_elapsed(interval * 2, interval));
1058
1059 assert!(!interval_elapsed(interval / 2, interval));
1060 assert!(!interval_elapsed(Duration::ZERO, interval));
1061 }
1062
1063 #[test]
1064 fn scaled_dims_preserves_aspect_ratio() {
1065 assert_eq!(scaled_dims(1920, 1080, Some(720)).unwrap(), (720, 405));
1066 assert_eq!(scaled_dims(1024, 1024, Some(512)).unwrap(), (512, 512));
1067 assert_eq!(scaled_dims(1080, 1920, Some(360)).unwrap(), (360, 640));
1068 assert_eq!(scaled_dims(800, 600, None).unwrap(), (800, 600));
1069 assert_eq!(scaled_dims(10000, 1, Some(100)).unwrap(), (100, 1));
1070 }
1071
1072 #[test]
1073 fn scaled_dims_rejects_zero_target() {
1074 assert!(scaled_dims(1920, 1080, Some(0)).is_err());
1075 assert!(scaled_dims(0, 1080, Some(720)).is_err());
1076 assert!(scaled_dims(1920, 0, Some(720)).is_err());
1077 }
1078}