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        // SAFETY: encode_image manages all FFmpeg resources internally and
167        // frees them before returning, whether on success or error.
168        unsafe { encoder_inner::encode_image(&self.path, frame, &opts) }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn create_should_return_builder() {
178        let _builder = ImageEncoder::create("out.png");
179    }
180
181    #[test]
182    fn new_should_return_builder() {
183        let _builder = ImageEncoder::new("out.png");
184    }
185
186    #[test]
187    fn build_with_unsupported_extension_should_return_error() {
188        let result = ImageEncoder::create("out.avi").build();
189        assert!(
190            matches!(result, Err(EncodeError::UnsupportedCodec { .. })),
191            "expected UnsupportedCodec, got {result:?}"
192        );
193    }
194
195    #[test]
196    fn build_with_no_extension_should_return_error() {
197        let result = ImageEncoder::create("out_no_ext").build();
198        assert!(
199            matches!(result, Err(EncodeError::InvalidConfig { .. })),
200            "expected InvalidConfig, got {result:?}"
201        );
202    }
203
204    #[test]
205    fn build_with_zero_width_should_return_error() {
206        let result = ImageEncoder::create("out.png").width(0).build();
207        assert!(
208            matches!(result, Err(EncodeError::InvalidConfig { .. })),
209            "expected InvalidConfig for zero width, got {result:?}"
210        );
211    }
212
213    #[test]
214    fn build_with_zero_height_should_return_error() {
215        let result = ImageEncoder::create("out.png").height(0).build();
216        assert!(
217            matches!(result, Err(EncodeError::InvalidConfig { .. })),
218            "expected InvalidConfig for zero height, got {result:?}"
219        );
220    }
221
222    #[test]
223    fn width_setter_should_store_value() {
224        let encoder = ImageEncoder::create("out.png").width(320).build().unwrap();
225        assert_eq!(encoder.width, Some(320));
226    }
227
228    #[test]
229    fn height_setter_should_store_value() {
230        let encoder = ImageEncoder::create("out.png").height(240).build().unwrap();
231        assert_eq!(encoder.height, Some(240));
232    }
233
234    #[test]
235    fn quality_setter_should_store_value() {
236        let encoder = ImageEncoder::create("out.png").quality(75).build().unwrap();
237        assert_eq!(encoder.quality, Some(75));
238    }
239
240    #[test]
241    fn pixel_format_setter_should_store_value() {
242        let encoder = ImageEncoder::create("out.png")
243            .pixel_format(PixelFormat::Rgb24)
244            .build()
245            .unwrap();
246        assert_eq!(encoder.pixel_format, Some(PixelFormat::Rgb24));
247    }
248
249    #[test]
250    fn build_with_only_width_should_succeed() {
251        // Partial dimensions are valid — height falls back to frame dimension.
252        let result = ImageEncoder::create("out.png").width(128).build();
253        assert!(result.is_ok(), "expected Ok, got {result:?}");
254    }
255
256    #[test]
257    fn build_with_all_options_should_succeed() {
258        let result = ImageEncoder::create("out.jpg")
259            .width(320)
260            .height(240)
261            .quality(80)
262            .pixel_format(PixelFormat::Yuv420p)
263            .build();
264        assert!(result.is_ok(), "expected Ok, got {result:?}");
265    }
266}