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/// The largest width or height libjpeg writes (`JPEG_MAX_DIMENSION`). The
25/// format's own field is 16 bits; the reference refuses past this, as libjpeg
26/// does, rather than writing a frame the field cannot describe.
27const MAX_DIMENSION: u32 = 65_500;
28
29/// Both dimensions as the encoder takes them, or the reference's refusal.
30fn dimensions(width: u32, height: u32) -> Result<(u16, u16), CaptureError> {
31    let fits = |value: u32| u16::try_from(value).ok().filter(|_| value <= MAX_DIMENSION);
32    fits(width).zip(fits(height)).ok_or_else(|| {
33        CaptureError::Io(std::io::Error::other(format!(
34            "{width}x{height} is past JPEG's {MAX_DIMENSION} pixel limit"
35        )))
36    })
37}
38
39/// An encoder writing 4:2:0, as the reference's libjpeg does at every quality.
40///
41/// `jpeg-encoder` keeps chroma at full resolution from quality 90 up, and 90
42/// is the quality the reference's callers ask for, so left to its default the
43/// common case would write a different and larger file than the reference.
44fn encoder(file: std::fs::File, quality: u8) -> jpeg_encoder::Encoder<BufWriter<std::fs::File>> {
45    let quality = quality.clamp(*QUALITY_RANGE.start(), *QUALITY_RANGE.end());
46    let mut encoder = jpeg_encoder::Encoder::new(BufWriter::new(file), quality);
47    encoder.set_sampling_factor(jpeg_encoder::SamplingFactor::F_2_2);
48    encoder
49}
50
51/// Write `image` as a baseline JPEG.
52///
53/// `quality` is clamped into `1..=100` rather than refused. It is a knob a
54/// caller passes through from a config file or a command line, and a
55/// screenshot is not worth failing over a number that only ever means
56/// "as good as possible" or "as small as possible" at the ends.
57///
58/// The clamp is belt-and-braces: the encoder accepts `0` and `255` without
59/// complaint, so removing it changes no observable behavior — verified by
60/// injection. It stays because that is the encoder's business rather than a
61/// promise, and a caller reading this signature should be able to trust the
62/// stated range without checking what the encoder does with the rest.
63///
64/// The pixels are un-premultiplied first, exactly as the PNG path does.
65/// Encoding premultiplied color without its alpha darkens every partly
66/// transparent pixel toward black — a translucent decoration would come out
67/// looking like a dark one, rather than in its own color at full opacity.
68///
69/// # Errors
70///
71/// [`CaptureError::EmptyImage`] for an image with no pixels, and
72/// [`CaptureError::Io`] if a dimension is past 65,500 pixels or the file
73/// cannot be written.
74pub fn write_jpg(image: &Image, path: impl AsRef<Path>, quality: u8) -> Result<(), CaptureError> {
75    if image.is_empty() {
76        return Err(CaptureError::EmptyImage);
77    }
78    let (width, height) = dimensions(image.width(), image.height())?;
79
80    // Three channels, not four: JPEG has no alpha, so the fourth is dropped
81    // here rather than encoded and ignored.
82    let mut rgb = Vec::with_capacity(image.pixels().len() * 3);
83    for pixel in image.pixels() {
84        let [r, g, b, _] = unpremultiply(*pixel);
85        rgb.extend_from_slice(&[r, g, b]);
86    }
87
88    let file = std::fs::File::create(path).map_err(CaptureError::Io)?;
89    encoder(file, quality)
90        .encode(&rgb, width, height, jpeg_encoder::ColorType::Rgb)
91        .map_err(|error| CaptureError::Io(std::io::Error::other(error)))
92}
93
94/// One `NV12` frame, as a camera or decoder hands it over.
95///
96/// Strides rather than packed rows because a capture buffer is padded to
97/// whatever the hardware wanted, and copying it tight first would be a second
98/// pass over every byte for nothing.
99#[derive(Debug, Clone, Copy)]
100#[allow(
101    clippy::exhaustive_structs,
102    reason = "built by callers, with no default to fill a new field from"
103)]
104pub struct Nv12Frame<'a> {
105    /// `height` rows of `luma_stride`.
106    pub luma: &'a [u8],
107    /// Bytes between luma rows.
108    pub luma_stride: usize,
109    /// `height.div_ceil(2)` rows of interleaved Cb/Cr, each at least
110    /// `width.div_ceil(2) * 2` bytes, at `chroma_stride`.
111    pub chroma: &'a [u8],
112    /// Bytes between chroma rows.
113    pub chroma_stride: usize,
114    /// Width in pixels.
115    pub width: u32,
116    /// Height in pixels.
117    pub height: u32,
118}
119
120/// Write an `NV12` frame as a baseline JPEG, without converting to RGB first.
121///
122/// The path a camera or a decoder takes. `NV12` is already YCbCr 4:2:0, which
123/// is what JPEG stores — so converting to RGB and back would lose a little
124/// color to two rounding trips and cost both conversions, for nothing.
125///
126/// # Errors
127///
128/// [`CaptureError::EmptyImage`] for zero dimensions, strides narrower than a
129/// row, or planes too short for the dimensions given, and [`CaptureError::Io`]
130/// if a dimension is past 65,500 pixels or the file cannot be written.
131pub fn write_jpg_nv12(
132    frame: &Nv12Frame<'_>,
133    path: impl AsRef<Path>,
134    quality: u8,
135) -> Result<(), CaptureError> {
136    let Nv12Frame {
137        luma,
138        luma_stride,
139        chroma,
140        chroma_stride,
141        width,
142        height,
143    } = *frame;
144    if width == 0 || height == 0 {
145        return Err(CaptureError::EmptyImage);
146    }
147    let (encoded_width, encoded_height) = dimensions(width, height)?;
148    let (w, h) = (width as usize, height as usize);
149    // Chroma is subsampled by two in both directions, and an odd dimension
150    // rounds *up* -- a 33-pixel-wide frame has 17 chroma columns, not 16, and
151    // sizing for 16 reads past the last row.
152    let chroma_rows = h.div_ceil(2);
153    if luma_stride < w
154        || chroma_stride < w.div_ceil(2) * 2
155        || luma.len() < luma_stride * h
156        || chroma.len() < chroma_stride * chroma_rows
157    {
158        return Err(CaptureError::EmptyImage);
159    }
160
161    // Repack to packed rows: the encoder wants no padding, and a camera
162    // buffer has whatever the hardware chose.
163    let mut packed_luma = Vec::with_capacity(w * h);
164    for row in 0..h {
165        let start = row * luma_stride;
166        packed_luma.extend_from_slice(&luma[start..start + w]);
167    }
168    let chroma_cols = w.div_ceil(2) * 2;
169    let mut packed_chroma = Vec::with_capacity(chroma_cols * chroma_rows);
170    for row in 0..chroma_rows {
171        let start = row * chroma_stride;
172        packed_chroma.extend_from_slice(&chroma[start..start + chroma_cols]);
173    }
174
175    let file = std::fs::File::create(path).map_err(CaptureError::Io)?;
176    encoder(file, quality)
177        .encode(
178            &interleave_nv12(&packed_luma, &packed_chroma, w, h),
179            encoded_width,
180            encoded_height,
181            jpeg_encoder::ColorType::Ycbcr,
182        )
183        .map_err(|error| CaptureError::Io(std::io::Error::other(error)))
184}
185
186/// Expand semi-planar `NV12` into the interleaved `YCbCr` the encoder takes.
187///
188/// The chroma planes are half-resolution, so each chroma sample is repeated
189/// across the two-by-two block of luma it covers. That is what 4:2:0 *means*;
190/// the encoder subsamples it again on the way out, arriving back where it
191/// started without a color-space round trip through RGB.
192fn interleave_nv12(luma: &[u8], chroma: &[u8], width: usize, height: usize) -> Vec<u8> {
193    let chroma_cols = width.div_ceil(2) * 2;
194    let mut out = Vec::with_capacity(width * height * 3);
195    for y in 0..height {
196        for x in 0..width {
197            let cb_index = (y / 2) * chroma_cols + (x / 2) * 2;
198            out.push(luma.get(y * width + x).copied().unwrap_or(0));
199            out.push(chroma.get(cb_index).copied().unwrap_or(128));
200            out.push(chroma.get(cb_index + 1).copied().unwrap_or(128));
201        }
202    }
203    out
204}