Skip to main content

ff_encode/image/
builder.rs

1//! Image encoder builder and public API.
2
3use std::path::{Path, PathBuf};
4
5use ff_format::{PixelFormat, VideoFrame};
6
7use crate::EncodeError;
8
9use super::encoder_inner;
10
11/// Builder for [`ImageEncoder`].
12///
13/// Created via [`ImageEncoder::create`] or [`ImageEncoder::new`]. Extension
14/// validation and zero-dimension checks happen in [`build`](ImageEncoderBuilder::build).
15#[derive(Debug)]
16pub struct ImageEncoderBuilder {
17    path: PathBuf,
18    width: Option<u32>,
19    height: Option<u32>,
20    quality: Option<u32>,
21    pixel_format: Option<PixelFormat>,
22}
23
24impl ImageEncoderBuilder {
25    pub(crate) fn new(path: PathBuf) -> Self {
26        Self {
27            path,
28            width: None,
29            height: None,
30            quality: None,
31            pixel_format: None,
32        }
33    }
34
35    /// Override the output width in pixels.
36    ///
37    /// If not set, the source frame's width is used. If only `width` is set
38    /// (without `height`), the source frame's height is preserved unchanged.
39    #[must_use]
40    pub fn width(mut self, w: u32) -> Self {
41        self.width = Some(w);
42        self
43    }
44
45    /// Override the output height in pixels.
46    ///
47    /// If not set, the source frame's height is used. If only `height` is set
48    /// (without `width`), the source frame's width is preserved unchanged.
49    #[must_use]
50    pub fn height(mut self, h: u32) -> Self {
51        self.height = Some(h);
52        self
53    }
54
55    /// Set encoder quality on a 0–100 scale (100 = best quality).
56    ///
57    /// The value is mapped per codec:
58    /// - **JPEG**: qscale 1–31 (100 → 1 = best, 0 → 31 = worst)
59    /// - **PNG**: compression level 0–9 (100 → 9 = maximum compression)
60    /// - **WebP**: quality 0–100 (direct mapping)
61    /// - **BMP / TIFF**: no quality concept; the value is ignored with a warning
62    #[must_use]
63    pub fn quality(mut self, q: u32) -> Self {
64        self.quality = Some(q);
65        self
66    }
67
68    /// Override the output pixel format.
69    ///
70    /// If not set, a codec-native default is used (e.g. `YUVJ420P` for JPEG,
71    /// `RGB24` for PNG). Setting an incompatible format may cause encoding to
72    /// fail with an FFmpeg error.
73    #[must_use]
74    pub fn pixel_format(mut self, fmt: PixelFormat) -> Self {
75        self.pixel_format = Some(fmt);
76        self
77    }
78
79    /// Validate settings and return an [`ImageEncoder`].
80    ///
81    /// # Errors
82    ///
83    /// - [`EncodeError::InvalidConfig`] — path has no extension, or width/height is zero
84    /// - [`EncodeError::UnsupportedCodec`] — extension is not a supported image format
85    pub fn build(self) -> Result<ImageEncoder, EncodeError> {
86        encoder_inner::codec_from_extension(&self.path)?;
87        if let Some(0) = self.width {
88            return Err(EncodeError::InvalidConfig {
89                reason: "width must be non-zero".to_string(),
90            });
91        }
92        if let Some(0) = self.height {
93            return Err(EncodeError::InvalidConfig {
94                reason: "height must be non-zero".to_string(),
95            });
96        }
97        Ok(ImageEncoder {
98            path: self.path,
99            width: self.width,
100            height: self.height,
101            quality: self.quality,
102            pixel_format: self.pixel_format,
103        })
104    }
105}
106
107/// Encodes a single [`VideoFrame`] to a still image file.
108///
109/// The output format is inferred from the file extension: `.jpg`/`.jpeg`,
110/// `.png`, `.bmp`, `.tif`/`.tiff`, or `.webp`.
111///
112/// # Example
113///
114/// ```ignore
115/// use ff_encode::ImageEncoder;
116/// use ff_format::PixelFormat;
117///
118/// let encoder = ImageEncoder::create("thumbnail.jpg")
119///     .width(320)
120///     .height(240)
121///     .quality(85)
122///     .build()?;
123/// encoder.encode(&frame)?;
124/// ```
125#[derive(Debug)]
126pub struct ImageEncoder {
127    path: PathBuf,
128    width: Option<u32>,
129    height: Option<u32>,
130    quality: Option<u32>,
131    pixel_format: Option<PixelFormat>,
132}
133
134impl ImageEncoder {
135    /// Start building an image encoder that writes to `path`.
136    ///
137    /// This is infallible; extension validation happens in
138    /// [`ImageEncoderBuilder::build`].
139    pub fn create(path: impl AsRef<Path>) -> ImageEncoderBuilder {
140        ImageEncoderBuilder::new(path.as_ref().to_path_buf())
141    }
142
143    /// Alias for [`create`](ImageEncoder::create).
144    #[allow(clippy::new_ret_no_self)]
145    pub fn new(path: impl AsRef<Path>) -> ImageEncoderBuilder {
146        ImageEncoderBuilder::new(path.as_ref().to_path_buf())
147    }
148
149    /// Encode `frame` and write it to the output file.
150    ///
151    /// If `width` or `height` were set on the builder and differ from the
152    /// source frame dimensions, swscale is used to resize. If `pixel_format`
153    /// was set and differs from the frame format, swscale performs conversion.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the FFmpeg encoder is unavailable, the output file
158    /// cannot be created, or encoding fails.
159    pub fn encode(self, frame: &VideoFrame) -> Result<(), EncodeError> {
160        let opts = encoder_inner::ImageEncodeOptions {
161            width: self.width,
162            height: self.height,
163            quality: self.quality,
164            pixel_format: self.pixel_format,
165        };
166        encoder_inner::encode_image(&self.path, frame, &opts)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn create_should_return_builder() {
176        let _builder = ImageEncoder::create("out.png");
177    }
178
179    #[test]
180    fn new_should_return_builder() {
181        let _builder = ImageEncoder::new("out.png");
182    }
183
184    #[test]
185    fn build_with_unsupported_extension_should_return_error() {
186        let result = ImageEncoder::create("out.avi").build();
187        assert!(
188            matches!(result, Err(EncodeError::UnsupportedCodec { .. })),
189            "expected UnsupportedCodec, got {result:?}"
190        );
191    }
192
193    #[test]
194    fn build_with_no_extension_should_return_error() {
195        let result = ImageEncoder::create("out_no_ext").build();
196        assert!(
197            matches!(result, Err(EncodeError::InvalidConfig { .. })),
198            "expected InvalidConfig, got {result:?}"
199        );
200    }
201
202    #[test]
203    fn build_with_zero_width_should_return_error() {
204        let result = ImageEncoder::create("out.png").width(0).build();
205        assert!(
206            matches!(result, Err(EncodeError::InvalidConfig { .. })),
207            "expected InvalidConfig for zero width, got {result:?}"
208        );
209    }
210
211    #[test]
212    fn build_with_zero_height_should_return_error() {
213        let result = ImageEncoder::create("out.png").height(0).build();
214        assert!(
215            matches!(result, Err(EncodeError::InvalidConfig { .. })),
216            "expected InvalidConfig for zero height, got {result:?}"
217        );
218    }
219
220    #[test]
221    fn width_setter_should_store_value() {
222        let encoder = ImageEncoder::create("out.png").width(320).build().unwrap();
223        assert_eq!(encoder.width, Some(320));
224    }
225
226    #[test]
227    fn height_setter_should_store_value() {
228        let encoder = ImageEncoder::create("out.png").height(240).build().unwrap();
229        assert_eq!(encoder.height, Some(240));
230    }
231
232    #[test]
233    fn quality_setter_should_store_value() {
234        let encoder = ImageEncoder::create("out.png").quality(75).build().unwrap();
235        assert_eq!(encoder.quality, Some(75));
236    }
237
238    #[test]
239    fn pixel_format_setter_should_store_value() {
240        let encoder = ImageEncoder::create("out.png")
241            .pixel_format(PixelFormat::Rgb24)
242            .build()
243            .unwrap();
244        assert_eq!(encoder.pixel_format, Some(PixelFormat::Rgb24));
245    }
246
247    #[test]
248    fn build_with_only_width_should_succeed() {
249        // Partial dimensions are valid — height falls back to frame dimension.
250        let result = ImageEncoder::create("out.png").width(128).build();
251        assert!(result.is_ok(), "expected Ok, got {result:?}");
252    }
253
254    #[test]
255    fn build_with_all_options_should_succeed() {
256        let result = ImageEncoder::create("out.jpg")
257            .width(320)
258            .height(240)
259            .quality(80)
260            .pixel_format(PixelFormat::Yuv420p)
261            .build();
262        assert!(result.is_ok(), "expected Ok, got {result:?}");
263    }
264}