Skip to main content

drmkit_capture/
jpg.rs

1// SPDX-FileCopyrightText: (c) 2026 Joel Winarske
2// SPDX-License-Identifier: MIT
3
4//! Writing a capture as a baseline JPEG.
5//!
6//! Port of `src/capture/jpg.{hpp,cpp}`. Behind the `jpeg` feature, as upstream
7//! puts it behind a build switch and for the same reason: a second image
8//! format is not something every consumer wants, and the dependency should
9//! follow the want.
10//!
11//! JPEG is lossy and has no alpha. Both matter to a caller choosing between
12//! this and PNG: a screenshot of a translucent decoration keeps its
13//! translucency in PNG and is flattened here, and text will show ringing.
14//! What it buys is size, which is why a camera frame goes out this way.
15
16use std::io::BufWriter;
17use std::path::Path;
18
19use crate::{CaptureError, Image, unpremultiply};
20
21/// The quality range a JPEG encoder accepts.
22const QUALITY_RANGE: std::ops::RangeInclusive<u8> = 1..=100;
23
24/// Write `image` as a baseline JPEG.
25///
26/// `quality` is clamped into `1..=100` rather than refused. It is a knob a
27/// caller passes through from a config file or a command line, and a
28/// screenshot is not worth failing over a number that only ever means
29/// "as good as possible" or "as small as possible" at the ends.
30///
31/// The clamp is belt-and-braces: the encoder accepts `0` and `255` without
32/// complaint, so removing it changes no observable behaviour — verified by
33/// injection. It stays because that is the encoder's business rather than a
34/// promise, and a caller reading this signature should be able to trust the
35/// stated range without checking what the encoder does with the rest.
36///
37/// The pixels are un-premultiplied first, exactly as the PNG path does.
38/// Encoding premultiplied colour without its alpha darkens every partly
39/// transparent pixel toward black — a translucent decoration would come out
40/// looking like a dark one, rather than like itself over white.
41///
42/// # Errors
43///
44/// [`CaptureError::EmptyImage`] for an image with no pixels, and
45/// [`CaptureError::Io`] if the file cannot be written.
46pub fn write_jpg(image: &Image, path: impl AsRef<Path>, quality: u8) -> Result<(), CaptureError> {
47    if image.is_empty() {
48        return Err(CaptureError::EmptyImage);
49    }
50
51    // Three channels, not four: JPEG has no alpha, so the fourth is dropped
52    // here rather than encoded and ignored.
53    let mut rgb = Vec::with_capacity(image.pixels().len() * 3);
54    for pixel in image.pixels() {
55        let [r, g, b, _] = unpremultiply(*pixel);
56        rgb.extend_from_slice(&[r, g, b]);
57    }
58
59    let file = std::fs::File::create(path).map_err(CaptureError::Io)?;
60    let quality = quality.clamp(*QUALITY_RANGE.start(), *QUALITY_RANGE.end());
61    let encoder = jpeg_encoder::Encoder::new(BufWriter::new(file), quality);
62    encoder
63        .encode(
64            &rgb,
65            u16::try_from(image.width()).unwrap_or(u16::MAX),
66            u16::try_from(image.height()).unwrap_or(u16::MAX),
67            jpeg_encoder::ColorType::Rgb,
68        )
69        .map_err(|error| CaptureError::Io(std::io::Error::other(error)))
70}
71
72/// One `NV12` frame, as a camera or decoder hands it over.
73///
74/// Strides rather than packed rows because a capture buffer is padded to
75/// whatever the hardware wanted, and copying it tight first would be a second
76/// pass over every byte for nothing.
77#[derive(Debug, Clone, Copy)]
78#[allow(
79    clippy::exhaustive_structs,
80    reason = "built by callers, with no default to fill a new field from"
81)]
82pub struct Nv12Frame<'a> {
83    /// `height` rows of `luma_stride`.
84    pub luma: &'a [u8],
85    /// Bytes between luma rows.
86    pub luma_stride: usize,
87    /// `height / 2` rows of interleaved Cb/Cr at `chroma_stride`.
88    pub chroma: &'a [u8],
89    /// Bytes between chroma rows.
90    pub chroma_stride: usize,
91    /// Width in pixels.
92    pub width: u32,
93    /// Height in pixels.
94    pub height: u32,
95}
96
97/// Write an `NV12` frame as a baseline JPEG, without converting to RGB first.
98///
99/// The path a camera or a decoder takes. `NV12` is already YCbCr 4:2:0, which
100/// is what JPEG stores — so converting to RGB and back would lose a little
101/// colour to two rounding trips and cost both conversions, for nothing.
102///
103/// # Errors
104///
105/// [`CaptureError::EmptyImage`] for zero dimensions or planes too short for
106/// the dimensions given, and [`CaptureError::Io`] if the file cannot be
107/// written.
108pub fn write_jpg_nv12(
109    frame: &Nv12Frame<'_>,
110    path: impl AsRef<Path>,
111    quality: u8,
112) -> Result<(), CaptureError> {
113    let Nv12Frame {
114        luma,
115        luma_stride,
116        chroma,
117        chroma_stride,
118        width,
119        height,
120    } = *frame;
121    if width == 0 || height == 0 {
122        return Err(CaptureError::EmptyImage);
123    }
124    let (w, h) = (width as usize, height as usize);
125    // Chroma is subsampled by two in both directions, and an odd dimension
126    // rounds *up* -- a 33-pixel-wide frame has 17 chroma columns, not 16, and
127    // sizing for 16 reads past the last row.
128    let chroma_rows = h.div_ceil(2);
129    if luma_stride < w
130        || chroma_stride < w.div_ceil(2) * 2
131        || luma.len() < luma_stride * h
132        || chroma.len() < chroma_stride * chroma_rows
133    {
134        return Err(CaptureError::EmptyImage);
135    }
136
137    // Repack to packed rows: the encoder wants no padding, and a camera
138    // buffer has whatever the hardware chose.
139    let mut packed_luma = Vec::with_capacity(w * h);
140    for row in 0..h {
141        let start = row * luma_stride;
142        packed_luma.extend_from_slice(&luma[start..start + w]);
143    }
144    let chroma_cols = w.div_ceil(2) * 2;
145    let mut packed_chroma = Vec::with_capacity(chroma_cols * chroma_rows);
146    for row in 0..chroma_rows {
147        let start = row * chroma_stride;
148        packed_chroma.extend_from_slice(&chroma[start..start + chroma_cols]);
149    }
150
151    let file = std::fs::File::create(path).map_err(CaptureError::Io)?;
152    let quality = quality.clamp(*QUALITY_RANGE.start(), *QUALITY_RANGE.end());
153    let encoder = jpeg_encoder::Encoder::new(BufWriter::new(file), quality);
154    encoder
155        .encode(
156            &interleave_nv12(&packed_luma, &packed_chroma, w, h),
157            u16::try_from(width).unwrap_or(u16::MAX),
158            u16::try_from(height).unwrap_or(u16::MAX),
159            jpeg_encoder::ColorType::Ycbcr,
160        )
161        .map_err(|error| CaptureError::Io(std::io::Error::other(error)))
162}
163
164/// Expand semi-planar `NV12` into the interleaved `YCbCr` the encoder takes.
165///
166/// The chroma planes are half-resolution, so each chroma sample is repeated
167/// across the two-by-two block of luma it covers. That is what 4:2:0 *means*;
168/// the encoder subsamples it again on the way out, arriving back where it
169/// started without a colour-space round trip through RGB.
170fn interleave_nv12(luma: &[u8], chroma: &[u8], width: usize, height: usize) -> Vec<u8> {
171    let chroma_cols = width.div_ceil(2) * 2;
172    let mut out = Vec::with_capacity(width * height * 3);
173    for y in 0..height {
174        for x in 0..width {
175            let cb_index = (y / 2) * chroma_cols + (x / 2) * 2;
176            out.push(luma.get(y * width + x).copied().unwrap_or(0));
177            out.push(chroma.get(cb_index).copied().unwrap_or(128));
178            out.push(chroma.get(cb_index + 1).copied().unwrap_or(128));
179        }
180    }
181    out
182}