Skip to main content

ff_encode/preset/
mod.rs

1//! Export preset types and predefined presets.
2//!
3//! [`ExportPreset`] bundles a [`VideoEncoderConfig`] and an [`AudioEncoderConfig`]
4//! into a named snapshot that can be applied to a [`VideoEncoderBuilder`] before
5//! calling `.build()`.
6//!
7//! # Examples
8//!
9//! ```ignore
10//! use ff_encode::{ExportPreset, VideoEncoder};
11//!
12//! let preset = ExportPreset::youtube_1080p();
13//! preset.validate()?;
14//!
15//! let mut encoder = preset
16//!     .apply_video(VideoEncoder::create("output.mp4"))
17//!     .build()?;
18//! ```
19
20mod presets;
21mod validation;
22
23use ff_format::{PixelFormat, VideoCodec};
24
25use crate::video::codec_options::VideoCodecOptions;
26use crate::{AudioCodec, BitrateMode, EncodeError, VideoEncoderBuilder};
27
28/// Configuration for the video stream of an export preset.
29///
30/// Fields with `Option` type are not applied to the builder when `None`,
31/// allowing the builder's existing values (or defaults) to be preserved.
32#[derive(Debug, Clone)]
33pub struct VideoEncoderConfig {
34    /// Video codec.
35    pub codec: VideoCodec,
36    /// Output width in pixels. `None` = preserve source width.
37    pub width: Option<u32>,
38    /// Output height in pixels. `None` = preserve source height.
39    pub height: Option<u32>,
40    /// Output frame rate. `None` = preserve source frame rate.
41    pub fps: Option<f64>,
42    /// Bitrate control mode.
43    pub bitrate_mode: BitrateMode,
44    /// Optional pixel format override. `None` lets the encoder choose.
45    pub pixel_format: Option<PixelFormat>,
46    /// Optional per-codec advanced options.
47    pub codec_options: Option<VideoCodecOptions>,
48}
49
50/// Configuration for the audio stream of an export preset.
51#[derive(Debug, Clone)]
52pub struct AudioEncoderConfig {
53    /// Audio codec.
54    pub codec: AudioCodec,
55    /// Sample rate in Hz (e.g. 48000).
56    pub sample_rate: u32,
57    /// Number of audio channels (1 = mono, 2 = stereo).
58    pub channels: u32,
59    /// Audio bitrate in bits per second (e.g. 192_000 = 192 kbps).
60    pub bitrate: u64,
61}
62
63/// A named export preset combining video and audio encoder configuration.
64///
65/// Create a predefined preset with [`ExportPreset::youtube_1080p()`] etc., or
66/// build a custom one as a struct literal. Call [`validate()`](Self::validate)
67/// before encoding to catch platform-constraint violations early.
68///
69/// `video: None` indicates an audio-only preset (e.g. [`podcast_mono`](Self::podcast_mono)).
70///
71/// # Examples
72///
73/// ```ignore
74/// use ff_encode::{ExportPreset, VideoEncoder};
75///
76/// let preset = ExportPreset::youtube_1080p();
77/// preset.validate()?;
78///
79/// let mut encoder = preset
80///     .apply_video(VideoEncoder::create("output.mp4"))
81///     .build()?;
82/// ```
83#[derive(Debug, Clone)]
84pub struct ExportPreset {
85    /// Human-readable name (e.g. `"youtube_1080p"`).
86    pub name: String,
87    /// Video encoder configuration. `None` = audio-only preset.
88    pub video: Option<VideoEncoderConfig>,
89    /// Audio encoder configuration.
90    pub audio: AudioEncoderConfig,
91}
92
93impl ExportPreset {
94    // ── Predefined presets ────────────────────────────────────────────────────
95
96    /// YouTube 1080p preset: H.264, CRF 18, 1920×1080, 30 fps, AAC 192 kbps.
97    #[must_use]
98    pub fn youtube_1080p() -> Self {
99        presets::youtube_1080p()
100    }
101
102    /// YouTube 4K preset: H.265, CRF 20, 3840×2160, 30 fps, AAC 256 kbps.
103    #[must_use]
104    pub fn youtube_4k() -> Self {
105        presets::youtube_4k()
106    }
107
108    /// Twitter/X preset: H.264, CRF 23, 1280×720, 30 fps, AAC 128 kbps.
109    #[must_use]
110    pub fn twitter() -> Self {
111        presets::twitter()
112    }
113
114    /// Instagram Square preset: H.264, CRF 23, 1080×1080, 30 fps, AAC 128 kbps.
115    #[must_use]
116    pub fn instagram_square() -> Self {
117        presets::instagram_square()
118    }
119
120    /// Instagram Reels preset: H.264, CRF 23, 1080×1920, 30 fps, AAC 128 kbps.
121    #[must_use]
122    pub fn instagram_reels() -> Self {
123        presets::instagram_reels()
124    }
125
126    /// Blu-ray 1080p preset: H.264, CRF 18, 1920×1080, 24 fps, AC-3 384 kbps.
127    #[must_use]
128    pub fn bluray_1080p() -> Self {
129        presets::bluray_1080p()
130    }
131
132    /// Podcast mono preset (audio-only): AAC 128 kbps, mono, 48 kHz.
133    #[must_use]
134    pub fn podcast_mono() -> Self {
135        presets::podcast_mono()
136    }
137
138    /// Lossless archive preset: FFV1 video (source resolution), FLAC audio.
139    #[must_use]
140    pub fn lossless_rgb() -> Self {
141        presets::lossless_rgb()
142    }
143
144    /// Web H.264 preset (VP9): VP9, CRF 33, 1280×720, 30 fps, Opus 128 kbps.
145    #[must_use]
146    pub fn web_h264() -> Self {
147        presets::web_h264()
148    }
149
150    // ── Validation ────────────────────────────────────────────────────────────
151
152    /// Validates this preset against platform-specific constraints.
153    ///
154    /// Call this before passing the preset to [`apply_video`](Self::apply_video)
155    /// or [`apply_audio`](Self::apply_audio) to surface constraint violations
156    /// before encoding begins.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`EncodeError::PresetConstraintViolation`] when a platform rule
161    /// is violated (e.g. fps > 60 on a YouTube preset, or wrong aspect ratio
162    /// on an Instagram preset).
163    pub fn validate(&self) -> Result<(), EncodeError> {
164        validation::validate_preset(self)
165    }
166
167    // ── Builder helpers ───────────────────────────────────────────────────────
168
169    /// Applies the video configuration to a [`VideoEncoderBuilder`].
170    ///
171    /// Sets the codec and bitrate mode unconditionally. Resolution and frame
172    /// rate are only applied when all three (`width`, `height`, `fps`) are
173    /// `Some`. Pixel format and per-codec options are applied when present.
174    ///
175    /// Returns `builder` unchanged when [`video`](Self::video) is `None`
176    /// (audio-only preset).
177    #[must_use]
178    pub fn apply_video(&self, builder: VideoEncoderBuilder) -> VideoEncoderBuilder {
179        let Some(ref v) = self.video else {
180            return builder;
181        };
182        let mut b = builder
183            .video_codec(v.codec)
184            .bitrate_mode(v.bitrate_mode.clone());
185        if let (Some(w), Some(h), Some(fps)) = (v.width, v.height, v.fps) {
186            b = b.video(w, h, fps);
187        }
188        if let Some(pf) = v.pixel_format {
189            b = b.pixel_format(pf);
190        }
191        if let Some(opts) = v.codec_options.clone() {
192            b = b.codec_options(opts);
193        }
194        b
195    }
196
197    /// Applies the audio configuration to a [`VideoEncoderBuilder`].
198    ///
199    /// Sets sample rate, channel count, codec, and bitrate.
200    #[must_use]
201    pub fn apply_audio(&self, builder: VideoEncoderBuilder) -> VideoEncoderBuilder {
202        builder
203            .audio(self.audio.sample_rate, self.audio.channels)
204            .audio_codec(self.audio.codec)
205            .audio_bitrate(self.audio.bitrate)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    // ── youtube_1080p ─────────────────────────────────────────────────────────
214
215    #[test]
216    fn youtube_1080p_preset_should_have_correct_codec() {
217        let preset = ExportPreset::youtube_1080p();
218        let video = preset
219            .video
220            .as_ref()
221            .expect("youtube_1080p must have video");
222        assert_eq!(video.codec, VideoCodec::H264);
223    }
224
225    #[test]
226    fn youtube_1080p_preset_should_have_correct_resolution() {
227        let preset = ExportPreset::youtube_1080p();
228        let video = preset
229            .video
230            .as_ref()
231            .expect("youtube_1080p must have video");
232        assert_eq!(video.width, Some(1920));
233        assert_eq!(video.height, Some(1080));
234    }
235
236    #[test]
237    fn youtube_1080p_preset_audio_should_be_aac_192kbps() {
238        let preset = ExportPreset::youtube_1080p();
239        assert_eq!(preset.audio.codec, AudioCodec::Aac);
240        assert_eq!(preset.audio.bitrate, 192_000);
241    }
242
243    // ── youtube_4k ───────────────────────────────────────────────────────────
244
245    #[test]
246    fn youtube_4k_preset_should_have_h265_codec() {
247        let preset = ExportPreset::youtube_4k();
248        let video = preset.video.as_ref().expect("youtube_4k must have video");
249        assert_eq!(video.codec, VideoCodec::H265);
250    }
251
252    #[test]
253    fn youtube_4k_preset_should_have_correct_resolution() {
254        let preset = ExportPreset::youtube_4k();
255        let video = preset.video.as_ref().expect("youtube_4k must have video");
256        assert_eq!(video.width, Some(3840));
257        assert_eq!(video.height, Some(2160));
258    }
259
260    #[test]
261    fn youtube_4k_preset_audio_should_be_aac_256kbps() {
262        let preset = ExportPreset::youtube_4k();
263        assert_eq!(preset.audio.codec, AudioCodec::Aac);
264        assert_eq!(preset.audio.bitrate, 256_000);
265    }
266
267    // ── lossless_rgb ─────────────────────────────────────────────────────────
268
269    #[test]
270    fn lossless_rgb_preset_should_have_ffv1_codec() {
271        let preset = ExportPreset::lossless_rgb();
272        let video = preset.video.as_ref().expect("lossless_rgb must have video");
273        assert_eq!(video.codec, VideoCodec::Ffv1);
274    }
275
276    #[test]
277    fn lossless_rgb_preset_should_preserve_source_resolution() {
278        let preset = ExportPreset::lossless_rgb();
279        let video = preset.video.as_ref().expect("lossless_rgb must have video");
280        assert!(video.width.is_none(), "lossless_rgb should not fix width");
281        assert!(video.height.is_none(), "lossless_rgb should not fix height");
282        assert!(video.fps.is_none(), "lossless_rgb should not fix fps");
283    }
284
285    // ── podcast_mono ─────────────────────────────────────────────────────────
286
287    #[test]
288    fn podcast_mono_preset_should_have_no_video() {
289        let preset = ExportPreset::podcast_mono();
290        assert!(
291            preset.video.is_none(),
292            "podcast_mono must be audio-only (video=None)"
293        );
294    }
295
296    #[test]
297    fn podcast_mono_preset_should_have_mono_audio() {
298        let preset = ExportPreset::podcast_mono();
299        assert_eq!(preset.audio.channels, 1);
300    }
301
302    // ── web_h264 ─────────────────────────────────────────────────────────────
303
304    #[test]
305    fn web_h264_preset_should_use_vp9_codec() {
306        let preset = ExportPreset::web_h264();
307        let video = preset.video.as_ref().expect("web_h264 must have video");
308        assert_eq!(video.codec, VideoCodec::Vp9);
309    }
310
311    // ── human-readable names ──────────────────────────────────────────────────
312
313    #[test]
314    fn all_presets_should_have_non_empty_names() {
315        let presets = [
316            ExportPreset::youtube_1080p(),
317            ExportPreset::youtube_4k(),
318            ExportPreset::twitter(),
319            ExportPreset::instagram_square(),
320            ExportPreset::instagram_reels(),
321            ExportPreset::bluray_1080p(),
322            ExportPreset::podcast_mono(),
323            ExportPreset::lossless_rgb(),
324            ExportPreset::web_h264(),
325        ];
326        for preset in &presets {
327            assert!(!preset.name.is_empty(), "preset name must not be empty");
328        }
329    }
330
331    // ── apply_video / apply_audio ─────────────────────────────────────────────
332
333    #[test]
334    fn custom_preset_should_apply_codec_to_builder() {
335        use crate::VideoEncoder;
336
337        let preset = ExportPreset {
338            name: "custom".to_string(),
339            video: Some(VideoEncoderConfig {
340                codec: VideoCodec::H264,
341                width: Some(1280),
342                height: Some(720),
343                fps: Some(24.0),
344                bitrate_mode: BitrateMode::Crf(23),
345                pixel_format: None,
346                codec_options: None,
347            }),
348            audio: AudioEncoderConfig {
349                codec: AudioCodec::Aac,
350                sample_rate: 44100,
351                channels: 2,
352                bitrate: 128_000,
353            },
354        };
355
356        let builder = preset.apply_video(VideoEncoder::create("out.mp4"));
357        assert_eq!(builder.video_codec, VideoCodec::H264);
358        assert_eq!(builder.video_width, Some(1280));
359        assert_eq!(builder.video_height, Some(720));
360    }
361
362    #[test]
363    fn preset_with_no_resolution_should_not_override_builder_resolution() {
364        use crate::VideoEncoder;
365
366        let preset = ExportPreset {
367            name: "no-res".to_string(),
368            video: Some(VideoEncoderConfig {
369                codec: VideoCodec::Ffv1,
370                width: None,
371                height: None,
372                fps: None,
373                bitrate_mode: BitrateMode::Crf(0),
374                pixel_format: None,
375                codec_options: None,
376            }),
377            audio: AudioEncoderConfig {
378                codec: AudioCodec::Flac,
379                sample_rate: 48000,
380                channels: 2,
381                bitrate: 0,
382            },
383        };
384
385        // The builder starts with no video dimensions set.
386        let builder = preset.apply_video(VideoEncoder::create("out.mkv"));
387        assert!(
388            builder.video_width.is_none(),
389            "apply_video should not set width when VideoEncoderConfig.width is None"
390        );
391        assert!(
392            builder.video_height.is_none(),
393            "apply_video should not set height when VideoEncoderConfig.height is None"
394        );
395    }
396
397    #[test]
398    fn audio_only_preset_apply_video_should_return_builder_unchanged() {
399        use crate::VideoEncoder;
400
401        let preset = ExportPreset::podcast_mono();
402        let builder_before = VideoEncoder::create("out.m4a");
403        let builder_after = preset.apply_video(VideoEncoder::create("out.m4a"));
404        // Both start from the same state; codec should still be default.
405        assert_eq!(
406            builder_before.video_codec, builder_after.video_codec,
407            "apply_video on audio-only preset must leave builder unchanged"
408        );
409    }
410
411    #[test]
412    fn apply_audio_should_set_sample_rate_and_bitrate() {
413        use crate::VideoEncoder;
414
415        let preset = ExportPreset::youtube_1080p();
416        let builder = preset.apply_audio(VideoEncoder::create("out.mp4"));
417        assert_eq!(builder.audio_sample_rate, Some(48000));
418        assert_eq!(builder.audio_bitrate, Some(192_000));
419    }
420
421    // ── validation ───────────────────────────────────────────────────────────
422
423    #[test]
424    fn youtube_1080p_preset_should_pass_validation() {
425        assert!(ExportPreset::youtube_1080p().validate().is_ok());
426    }
427
428    #[test]
429    fn youtube_4k_preset_should_pass_validation() {
430        assert!(ExportPreset::youtube_4k().validate().is_ok());
431    }
432
433    #[test]
434    fn instagram_square_preset_should_pass_validation() {
435        assert!(ExportPreset::instagram_square().validate().is_ok());
436    }
437
438    #[test]
439    fn instagram_reels_preset_should_pass_validation() {
440        assert!(ExportPreset::instagram_reels().validate().is_ok());
441    }
442}