Skip to main content

loam_app/
capture.rs

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
102// ~1 s at 30 fps; ~57 MB at 800x600, released after training.
103const 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
121// Bounds worker lag to ~270 ms at 30 fps.
122const 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        // Fallback for a worker never detached, such as a panic unwinding through `Capture`.
170        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        // NeuQuant, Dekker (1994).
281        gif::Frame::from_rgba_speed(w_u16, h_u16, &mut buf, 10)
282    };
283    gif_frame.delay = delay_cs;
284    // `Background` disposal flashes between frames.
285    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
304// In-flight capacity only; the memory ceiling is the worker's internal Vec.
305const 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    // num_plays=0 loops infinitely.
374    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        // The first frame has no gap; 33 ms is ~30 fps.
404        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    // Stride 16 feeds ~110 K samples at 800x600 over 30 frames.
863    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            // Opaque alpha is normalized so NeuQuant's 4D metric is not biased.
868            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    // A zero-delay GIF frame is rejected.
892    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
904// Row-major RGBA8, already R/B-swapped from BGRA sources.
905pub(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}