Skip to main content

oxihuman_export/
screenshot_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Screenshot capture to PNG-like byte buffer (simple PPM/raw/TGA format, no external deps).
5
6#[allow(dead_code)]
7#[derive(Clone, PartialEq, Debug)]
8pub enum ScreenshotFormat {
9    Ppm,
10    Raw,
11    Tga,
12}
13
14#[allow(dead_code)]
15pub struct ScreenshotConfig {
16    pub width: u32,
17    pub height: u32,
18    pub format: ScreenshotFormat,
19    pub gamma_correct: bool,
20    pub include_alpha: bool,
21}
22
23#[allow(dead_code)]
24pub struct ScreenshotBuffer {
25    pub pixels: Vec<u8>, // RGBA u8 pixels
26    pub width: u32,
27    pub height: u32,
28    pub format: ScreenshotFormat,
29}
30
31#[allow(dead_code)]
32pub fn default_screenshot_config(width: u32, height: u32) -> ScreenshotConfig {
33    ScreenshotConfig {
34        width,
35        height,
36        format: ScreenshotFormat::Ppm,
37        gamma_correct: false,
38        include_alpha: false,
39    }
40}
41
42#[allow(dead_code)]
43pub fn new_screenshot_buffer(width: u32, height: u32) -> ScreenshotBuffer {
44    let size = (width as usize) * (height as usize) * 4;
45    ScreenshotBuffer {
46        pixels: vec![0u8; size],
47        width,
48        height,
49        format: ScreenshotFormat::Raw,
50    }
51}
52
53#[allow(dead_code)]
54pub fn set_pixel(buf: &mut ScreenshotBuffer, x: u32, y: u32, rgba: [u8; 4]) {
55    if x >= buf.width || y >= buf.height {
56        return;
57    }
58    let base = ((y as usize) * (buf.width as usize) + (x as usize)) * 4;
59    if base + 3 < buf.pixels.len() {
60        buf.pixels[base] = rgba[0];
61        buf.pixels[base + 1] = rgba[1];
62        buf.pixels[base + 2] = rgba[2];
63        buf.pixels[base + 3] = rgba[3];
64    }
65}
66
67#[allow(dead_code)]
68pub fn get_pixel(buf: &ScreenshotBuffer, x: u32, y: u32) -> [u8; 4] {
69    if x >= buf.width || y >= buf.height {
70        return [0u8; 4];
71    }
72    let base = ((y as usize) * (buf.width as usize) + (x as usize)) * 4;
73    if base + 3 < buf.pixels.len() {
74        [
75            buf.pixels[base],
76            buf.pixels[base + 1],
77            buf.pixels[base + 2],
78            buf.pixels[base + 3],
79        ]
80    } else {
81        [0u8; 4]
82    }
83}
84
85/// Encode as PPM binary format (P6).
86/// PPM does not include alpha; only RGB is written.
87#[allow(dead_code)]
88pub fn encode_ppm(buf: &ScreenshotBuffer) -> Vec<u8> {
89    let header = format!("P6\n{} {}\n255\n", buf.width, buf.height);
90    let pixel_count = (buf.width as usize) * (buf.height as usize);
91    let mut out = Vec::with_capacity(header.len() + pixel_count * 3);
92    out.extend_from_slice(header.as_bytes());
93    for i in 0..pixel_count {
94        let base = i * 4;
95        if base + 2 < buf.pixels.len() {
96            out.push(buf.pixels[base]);
97            out.push(buf.pixels[base + 1]);
98            out.push(buf.pixels[base + 2]);
99        } else {
100            out.extend_from_slice(&[0u8, 0u8, 0u8]);
101        }
102    }
103    out
104}
105
106/// Encode as TGA (type 2, uncompressed true-color).
107#[allow(dead_code)]
108pub fn encode_tga(buf: &ScreenshotBuffer) -> Vec<u8> {
109    let w = buf.width as u16;
110    let h = buf.height as u16;
111    // TGA header: 18 bytes
112    let mut out = Vec::with_capacity(18 + (buf.width as usize) * (buf.height as usize) * 4);
113    out.push(0); // ID length
114    out.push(0); // color map type: none
115    out.push(2); // image type: uncompressed true-color
116    out.extend_from_slice(&[0u8; 5]); // color map spec (not used)
117    out.extend_from_slice(&0u16.to_le_bytes()); // x origin
118    out.extend_from_slice(&0u16.to_le_bytes()); // y origin
119    out.extend_from_slice(&w.to_le_bytes()); // width
120    out.extend_from_slice(&h.to_le_bytes()); // height
121    out.push(32); // bits per pixel (BGRA)
122    out.push(0x08); // image descriptor: 8 bits alpha, origin top-left
123
124    let pixel_count = (buf.width as usize) * (buf.height as usize);
125    for i in 0..pixel_count {
126        let base = i * 4;
127        if base + 3 < buf.pixels.len() {
128            // TGA stores BGRA
129            out.push(buf.pixels[base + 2]); // B
130            out.push(buf.pixels[base + 1]); // G
131            out.push(buf.pixels[base]); // R
132            out.push(buf.pixels[base + 3]); // A
133        } else {
134            out.extend_from_slice(&[0u8; 4]);
135        }
136    }
137    out
138}
139
140/// Encode as raw RGBA bytes (no header).
141#[allow(dead_code)]
142pub fn encode_raw(buf: &ScreenshotBuffer) -> Vec<u8> {
143    buf.pixels.clone()
144}
145
146#[allow(dead_code)]
147pub fn apply_gamma_correction(buf: &mut ScreenshotBuffer, gamma: f32) {
148    let inv_gamma = if gamma.abs() < 1e-6 { 1.0 } else { 1.0 / gamma };
149    for (i, pixel) in buf.pixels.iter_mut().enumerate() {
150        // Skip alpha channel (every 4th byte)
151        if i % 4 != 3 {
152            let linear = (*pixel as f32) / 255.0;
153            let corrected = linear.powf(inv_gamma);
154            *pixel = (corrected * 255.0).round().clamp(0.0, 255.0) as u8;
155        }
156    }
157}
158
159#[allow(dead_code)]
160pub fn flip_vertical(buf: &mut ScreenshotBuffer) {
161    let row_bytes = (buf.width as usize) * 4;
162    let height = buf.height as usize;
163    for row in 0..height / 2 {
164        let top = row * row_bytes;
165        let bot = (height - 1 - row) * row_bytes;
166        for col in 0..row_bytes {
167            buf.pixels.swap(top + col, bot + col);
168        }
169    }
170}
171
172#[allow(dead_code)]
173pub fn crop_screenshot(buf: &ScreenshotBuffer, x: u32, y: u32, w: u32, h: u32) -> ScreenshotBuffer {
174    let x = x.min(buf.width);
175    let y = y.min(buf.height);
176    let w = w.min(buf.width.saturating_sub(x));
177    let h = h.min(buf.height.saturating_sub(y));
178    let mut out = new_screenshot_buffer(w, h);
179    for row in 0..h {
180        for col in 0..w {
181            let src_base = (((y + row) as usize) * (buf.width as usize) + ((x + col) as usize)) * 4;
182            let dst_base = ((row as usize) * (w as usize) + (col as usize)) * 4;
183            if src_base + 3 < buf.pixels.len() && dst_base + 3 < out.pixels.len() {
184                out.pixels[dst_base] = buf.pixels[src_base];
185                out.pixels[dst_base + 1] = buf.pixels[src_base + 1];
186                out.pixels[dst_base + 2] = buf.pixels[src_base + 2];
187                out.pixels[dst_base + 3] = buf.pixels[src_base + 3];
188            }
189        }
190    }
191    out
192}
193
194#[allow(dead_code)]
195pub fn screenshot_size_bytes(cfg: &ScreenshotConfig) -> usize {
196    let pixels = (cfg.width as usize) * (cfg.height as usize);
197    match cfg.format {
198        ScreenshotFormat::Ppm => {
199            // header is at most ~30 bytes
200            let header_est = format!("P6\n{} {}\n255\n", cfg.width, cfg.height).len();
201            header_est + pixels * 3
202        }
203        ScreenshotFormat::Raw => pixels * 4,
204        ScreenshotFormat::Tga => 18 + pixels * 4,
205    }
206}
207
208#[allow(dead_code)]
209pub fn blend_overlay(base: &mut ScreenshotBuffer, overlay: &ScreenshotBuffer, alpha: f32) {
210    let alpha = alpha.clamp(0.0, 1.0);
211    let w = base.width.min(overlay.width) as usize;
212    let h = base.height.min(overlay.height) as usize;
213    for row in 0..h {
214        for col in 0..w {
215            let bi = (row * (base.width as usize) + col) * 4;
216            let oi = (row * (overlay.width as usize) + col) * 4;
217            if bi + 3 < base.pixels.len() && oi + 3 < overlay.pixels.len() {
218                for c in 0..4 {
219                    let bv = base.pixels[bi + c] as f32;
220                    let ov = overlay.pixels[oi + c] as f32;
221                    base.pixels[bi + c] =
222                        (bv * (1.0 - alpha) + ov * alpha).round().clamp(0.0, 255.0) as u8;
223                }
224            }
225        }
226    }
227}
228
229#[allow(dead_code)]
230pub fn clear_screenshot(buf: &mut ScreenshotBuffer, color: [u8; 4]) {
231    let pixel_count = (buf.width as usize) * (buf.height as usize);
232    for i in 0..pixel_count {
233        let base = i * 4;
234        if base + 3 < buf.pixels.len() {
235            buf.pixels[base] = color[0];
236            buf.pixels[base + 1] = color[1];
237            buf.pixels[base + 2] = color[2];
238            buf.pixels[base + 3] = color[3];
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn test_new_screenshot_buffer() {
249        let buf = new_screenshot_buffer(4, 4);
250        assert_eq!(buf.width, 4);
251        assert_eq!(buf.height, 4);
252        assert_eq!(buf.pixels.len(), 4 * 4 * 4);
253        assert!(buf.pixels.iter().all(|&b| b == 0));
254    }
255
256    #[test]
257    fn test_set_and_get_pixel() {
258        let mut buf = new_screenshot_buffer(10, 10);
259        set_pixel(&mut buf, 3, 5, [255, 128, 64, 255]);
260        let px = get_pixel(&buf, 3, 5);
261        assert_eq!(px, [255, 128, 64, 255]);
262    }
263
264    #[test]
265    fn test_get_pixel_out_of_bounds() {
266        let buf = new_screenshot_buffer(4, 4);
267        let px = get_pixel(&buf, 100, 100);
268        assert_eq!(px, [0u8; 4]);
269    }
270
271    #[test]
272    fn test_encode_ppm_starts_with_p6() {
273        let buf = new_screenshot_buffer(2, 2);
274        let ppm = encode_ppm(&buf);
275        let header = std::str::from_utf8(&ppm[..2]).expect("should succeed");
276        assert_eq!(header, "P6");
277    }
278
279    #[test]
280    fn test_encode_ppm_correct_size() {
281        let buf = new_screenshot_buffer(3, 3);
282        let ppm = encode_ppm(&buf);
283        let header = "P6\n3 3\n255\n".to_string();
284        let expected_len = header.len() + 3 * 3 * 3; // RGB, not RGBA
285        assert_eq!(ppm.len(), expected_len);
286    }
287
288    #[test]
289    fn test_encode_tga_minimum_size() {
290        let buf = new_screenshot_buffer(4, 4);
291        let tga = encode_tga(&buf);
292        assert!(
293            tga.len() >= 18,
294            "TGA should at least have the 18-byte header"
295        );
296    }
297
298    #[test]
299    fn test_encode_tga_header_image_type() {
300        let buf = new_screenshot_buffer(2, 2);
301        let tga = encode_tga(&buf);
302        assert_eq!(
303            tga[2], 2,
304            "TGA image type should be 2 (uncompressed true-color)"
305        );
306    }
307
308    #[test]
309    fn test_encode_raw_length() {
310        let buf = new_screenshot_buffer(5, 5);
311        let raw = encode_raw(&buf);
312        assert_eq!(
313            raw.len(),
314            5 * 5 * 4,
315            "raw should be exactly width*height*4 bytes"
316        );
317    }
318
319    #[test]
320    fn test_flip_vertical() {
321        let mut buf = new_screenshot_buffer(2, 2);
322        set_pixel(&mut buf, 0, 0, [255, 0, 0, 255]); // top-left red
323        set_pixel(&mut buf, 0, 1, [0, 0, 255, 255]); // bottom-left blue
324        flip_vertical(&mut buf);
325        // After flip: top-left should now be blue, bottom-left should be red
326        assert_eq!(get_pixel(&buf, 0, 0), [0, 0, 255, 255]);
327        assert_eq!(get_pixel(&buf, 0, 1), [255, 0, 0, 255]);
328    }
329
330    #[test]
331    fn test_crop_screenshot() {
332        let mut buf = new_screenshot_buffer(4, 4);
333        set_pixel(&mut buf, 2, 2, [100, 200, 50, 255]);
334        let cropped = crop_screenshot(&buf, 2, 2, 2, 2);
335        assert_eq!(cropped.width, 2);
336        assert_eq!(cropped.height, 2);
337        assert_eq!(get_pixel(&cropped, 0, 0), [100, 200, 50, 255]);
338    }
339
340    #[test]
341    fn test_clear_screenshot() {
342        let mut buf = new_screenshot_buffer(3, 3);
343        clear_screenshot(&mut buf, [128, 64, 32, 255]);
344        for y in 0..3 {
345            for x in 0..3 {
346                assert_eq!(get_pixel(&buf, x, y), [128, 64, 32, 255]);
347            }
348        }
349    }
350
351    #[test]
352    fn test_screenshot_size_bytes_raw() {
353        let cfg = ScreenshotConfig {
354            width: 10,
355            height: 10,
356            format: ScreenshotFormat::Raw,
357            gamma_correct: false,
358            include_alpha: false,
359        };
360        assert_eq!(screenshot_size_bytes(&cfg), 10 * 10 * 4);
361    }
362
363    #[test]
364    fn test_screenshot_size_bytes_tga() {
365        let cfg = ScreenshotConfig {
366            width: 8,
367            height: 8,
368            format: ScreenshotFormat::Tga,
369            gamma_correct: false,
370            include_alpha: true,
371        };
372        assert_eq!(screenshot_size_bytes(&cfg), 18 + 8 * 8 * 4);
373    }
374
375    #[test]
376    fn test_blend_overlay() {
377        let mut base = new_screenshot_buffer(2, 2);
378        clear_screenshot(&mut base, [0, 0, 0, 255]);
379        let mut overlay = new_screenshot_buffer(2, 2);
380        clear_screenshot(&mut overlay, [200, 200, 200, 255]);
381        blend_overlay(&mut base, &overlay, 0.5);
382        let px = get_pixel(&base, 0, 0);
383        // 0 * 0.5 + 200 * 0.5 = 100 (rounded)
384        assert_eq!(px[0], 100);
385    }
386
387    #[test]
388    fn test_apply_gamma_correction() {
389        let mut buf = new_screenshot_buffer(1, 1);
390        set_pixel(&mut buf, 0, 0, [128, 128, 128, 255]);
391        apply_gamma_correction(&mut buf, 2.2);
392        let px = get_pixel(&buf, 0, 0);
393        // After gamma correction the value changes, alpha stays
394        assert_eq!(px[3], 255, "alpha should be unchanged");
395        // value should differ from 128 after gamma
396        assert_ne!(px[0], 128, "gamma correction should change the value");
397    }
398
399    #[test]
400    fn test_default_screenshot_config() {
401        let cfg = default_screenshot_config(1920, 1080);
402        assert_eq!(cfg.width, 1920);
403        assert_eq!(cfg.height, 1080);
404        assert_eq!(cfg.format, ScreenshotFormat::Ppm);
405        assert!(!cfg.gamma_correct);
406    }
407}