Skip to main content

oximedia_transcode/
transcode_profile.rs

1//! TranscodeProfile — shareable encoding configuration with JSON import/export.
2//!
3//! A `TranscodeProfile` encapsulates all the settings needed to describe a
4//! complete transcode operation (codecs, bitrates, quality, filters) and can
5//! be serialised to / deserialised from JSON for sharing between operators,
6//! pre-sets libraries, and tooling integration.
7
8use serde::{Deserialize, Serialize};
9
10use crate::{LoudnessStandard, MultiPassMode, QualityMode, Result, TranscodeError};
11
12// ─── Profile components ───────────────────────────────────────────────────────
13
14/// Video encoding parameters within a `TranscodeProfile`.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct VideoProfileParams {
17    /// Codec name (e.g. `"h264"`, `"av1"`, `"vp9"`).
18    pub codec: String,
19    /// Target bitrate in bits per second.  `None` means CRF/quality-based.
20    pub bitrate_bps: Option<u64>,
21    /// Constant Rate Factor (0–63 for AV1, 0–51 for H.264).
22    pub crf: Option<u8>,
23    /// Encoder speed preset (e.g. `"slow"`, `"medium"`, `"fast"`).
24    pub preset: Option<String>,
25    /// Codec profile (e.g. `"high"`, `"main"`, `"baseline"`).
26    pub profile: Option<String>,
27    /// Output width in pixels.
28    pub width: Option<u32>,
29    /// Output height in pixels.
30    pub height: Option<u32>,
31    /// Output frame rate as `(numerator, denominator)`.
32    pub frame_rate: Option<(u32, u32)>,
33    /// Number of encoding threads (0 = auto).
34    pub threads: u32,
35    /// Quality mode used when CRF and bitrate are both absent.
36    pub quality_mode: Option<QualityMode>,
37    /// Enable row-based multi-threading (AV1 / VP9).
38    pub row_mt: bool,
39    /// Number of tile columns (AV1 tile-based parallel encoding, log2).
40    pub tile_columns: Option<u8>,
41    /// Number of tile rows (AV1 tile-based parallel encoding, log2).
42    pub tile_rows: Option<u8>,
43}
44
45impl Default for VideoProfileParams {
46    fn default() -> Self {
47        Self {
48            codec: "h264".into(),
49            bitrate_bps: None,
50            crf: Some(23),
51            preset: Some("medium".into()),
52            profile: Some("high".into()),
53            width: None,
54            height: None,
55            frame_rate: None,
56            threads: 0,
57            quality_mode: None,
58            row_mt: true,
59            tile_columns: None,
60            tile_rows: None,
61        }
62    }
63}
64
65/// Audio encoding parameters within a `TranscodeProfile`.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct AudioProfileParams {
68    /// Codec name (e.g. `"aac"`, `"opus"`, `"flac"`).
69    pub codec: String,
70    /// Target bitrate in bits per second.
71    pub bitrate_bps: u64,
72    /// Output sample rate in Hz.
73    pub sample_rate: u32,
74    /// Number of output channels.
75    pub channels: u8,
76    /// Whether to apply integrated-loudness normalisation.
77    pub normalize: bool,
78    /// Loudness target standard.
79    pub loudness_standard: Option<LoudnessStandard>,
80    /// Target loudness in LUFS (overrides `loudness_standard` when set).
81    pub target_lufs: Option<f64>,
82}
83
84impl Default for AudioProfileParams {
85    fn default() -> Self {
86        Self {
87            codec: "aac".into(),
88            bitrate_bps: 192_000,
89            sample_rate: 48_000,
90            channels: 2,
91            normalize: false,
92            loudness_standard: None,
93            target_lufs: None,
94        }
95    }
96}
97
98/// Container / muxer settings within a `TranscodeProfile`.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct ContainerParams {
101    /// Container format (e.g. `"mp4"`, `"mkv"`, `"webm"`).
102    pub format: String,
103    /// Whether to fast-start MP4 for web delivery (moov atom at front).
104    pub mp4_fast_start: bool,
105    /// Whether to preserve all metadata from the source.
106    pub preserve_metadata: bool,
107}
108
109impl Default for ContainerParams {
110    fn default() -> Self {
111        Self {
112            format: "mkv".into(),
113            mp4_fast_start: false,
114            preserve_metadata: true,
115        }
116    }
117}
118
119// ─── TranscodeProfile ─────────────────────────────────────────────────────────
120
121/// A complete, shareable encoding configuration.
122///
123/// # Example (round-trip)
124///
125/// ```
126/// use oximedia_transcode::transcode_profile::TranscodeProfile;
127///
128/// let profile = TranscodeProfile::youtube_1080p();
129/// let json = profile.to_json().expect("serialise");
130/// let loaded = TranscodeProfile::from_json(&json).expect("deserialise");
131/// assert_eq!(loaded.name, profile.name);
132/// ```
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub struct TranscodeProfile {
135    /// Human-readable name for this profile (e.g. `"YouTube 1080p"`).
136    pub name: String,
137    /// Optional description explaining intended use.
138    pub description: Option<String>,
139    /// Profile schema version (for future migrations).
140    pub version: u32,
141    /// Video encoding parameters.
142    pub video: VideoProfileParams,
143    /// Audio encoding parameters.
144    pub audio: AudioProfileParams,
145    /// Container / muxer parameters.
146    pub container: ContainerParams,
147    /// Multi-pass encoding mode.
148    pub multi_pass: MultiPassMode,
149    /// Arbitrary key/value tags for tooling metadata.
150    pub tags: Vec<(String, String)>,
151}
152
153impl TranscodeProfile {
154    /// Creates a blank profile with default parameters.
155    #[must_use]
156    pub fn new(name: impl Into<String>) -> Self {
157        Self {
158            name: name.into(),
159            description: None,
160            version: 1,
161            video: VideoProfileParams::default(),
162            audio: AudioProfileParams::default(),
163            container: ContainerParams::default(),
164            multi_pass: MultiPassMode::SinglePass,
165            tags: Vec::new(),
166        }
167    }
168
169    /// Sets the description.
170    #[must_use]
171    pub fn description(mut self, desc: impl Into<String>) -> Self {
172        self.description = Some(desc.into());
173        self
174    }
175
176    /// Sets the video parameters.
177    #[must_use]
178    pub fn video(mut self, params: VideoProfileParams) -> Self {
179        self.video = params;
180        self
181    }
182
183    /// Sets the audio parameters.
184    #[must_use]
185    pub fn audio(mut self, params: AudioProfileParams) -> Self {
186        self.audio = params;
187        self
188    }
189
190    /// Sets the container parameters.
191    #[must_use]
192    pub fn container(mut self, params: ContainerParams) -> Self {
193        self.container = params;
194        self
195    }
196
197    /// Sets the multi-pass mode.
198    #[must_use]
199    pub fn multi_pass(mut self, mode: MultiPassMode) -> Self {
200        self.multi_pass = mode;
201        self
202    }
203
204    /// Adds a tag to the profile.
205    #[must_use]
206    pub fn tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
207        self.tags.push((key.into(), value.into()));
208        self
209    }
210
211    // ── Built-in presets ──────────────────────────────────────────────────────
212
213    /// YouTube 1080p upload profile (H.264 + AAC).
214    #[must_use]
215    pub fn youtube_1080p() -> Self {
216        Self::new("YouTube 1080p")
217            .description("H.264 High 1080p for YouTube upload")
218            .video(VideoProfileParams {
219                codec: "h264".into(),
220                crf: Some(18),
221                preset: Some("slow".into()),
222                profile: Some("high".into()),
223                width: Some(1920),
224                height: Some(1080),
225                frame_rate: Some((30, 1)),
226                ..VideoProfileParams::default()
227            })
228            .audio(AudioProfileParams {
229                codec: "aac".into(),
230                bitrate_bps: 192_000,
231                sample_rate: 48_000,
232                channels: 2,
233                normalize: true,
234                loudness_standard: Some(LoudnessStandard::EbuR128),
235                ..AudioProfileParams::default()
236            })
237            .container(ContainerParams {
238                format: "mp4".into(),
239                mp4_fast_start: true,
240                preserve_metadata: true,
241            })
242    }
243
244    /// Podcast / audio-only Opus profile (EBU R128 normalised).
245    #[must_use]
246    pub fn podcast_opus() -> Self {
247        Self::new("Podcast Opus")
248            .description("Opus mono/stereo for podcast distribution (EBU R128 −23 LUFS)")
249            .video(VideoProfileParams {
250                codec: "none".into(),
251                ..VideoProfileParams::default()
252            })
253            .audio(AudioProfileParams {
254                codec: "opus".into(),
255                bitrate_bps: 96_000,
256                sample_rate: 48_000,
257                channels: 2,
258                normalize: true,
259                loudness_standard: Some(LoudnessStandard::EbuR128),
260                ..AudioProfileParams::default()
261            })
262            .container(ContainerParams {
263                format: "ogg".into(),
264                mp4_fast_start: false,
265                preserve_metadata: true,
266            })
267    }
268
269    /// AV1 1080p archive profile (CRF 28, tile-based parallel).
270    #[must_use]
271    pub fn av1_1080p_archive() -> Self {
272        Self::new("AV1 1080p Archive")
273            .description("High-efficiency AV1 1080p for long-term archival")
274            .video(VideoProfileParams {
275                codec: "av1".into(),
276                crf: Some(28),
277                preset: Some("5".into()),
278                width: Some(1920),
279                height: Some(1080),
280                row_mt: true,
281                tile_columns: Some(2),
282                tile_rows: Some(1),
283                ..VideoProfileParams::default()
284            })
285            .audio(AudioProfileParams {
286                codec: "opus".into(),
287                bitrate_bps: 192_000,
288                ..AudioProfileParams::default()
289            })
290            .container(ContainerParams {
291                format: "mkv".into(),
292                ..ContainerParams::default()
293            })
294            .multi_pass(MultiPassMode::TwoPass)
295    }
296
297    // ── JSON serialisation ────────────────────────────────────────────────────
298
299    /// Serialises the profile to a JSON string.
300    ///
301    /// # Errors
302    ///
303    /// Returns an error if serialisation fails (should not happen for valid profiles).
304    pub fn to_json(&self) -> Result<String> {
305        serde_json::to_string_pretty(self)
306            .map_err(|e| TranscodeError::CodecError(format!("Profile serialisation failed: {e}")))
307    }
308
309    /// Serialises the profile to a compact (non-pretty) JSON string.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if serialisation fails.
314    pub fn to_json_compact(&self) -> Result<String> {
315        serde_json::to_string(self)
316            .map_err(|e| TranscodeError::CodecError(format!("Profile serialisation failed: {e}")))
317    }
318
319    /// Deserialises a profile from a JSON string.
320    ///
321    /// # Errors
322    ///
323    /// Returns an error if the JSON is malformed or the schema does not match.
324    pub fn from_json(json: &str) -> Result<Self> {
325        serde_json::from_str(json).map_err(|e| {
326            TranscodeError::InvalidInput(format!("Profile deserialisation failed: {e}"))
327        })
328    }
329
330    /// Saves the profile to a file in JSON format.
331    ///
332    /// # Errors
333    ///
334    /// Returns an error if the file cannot be written.
335    pub fn save_to_file(&self, path: &std::path::Path) -> Result<()> {
336        let json = self.to_json()?;
337        std::fs::write(path, json.as_bytes()).map_err(|e| {
338            TranscodeError::IoError(format!("Cannot write profile to '{}': {e}", path.display()))
339        })
340    }
341
342    /// Loads a profile from a JSON file.
343    ///
344    /// # Errors
345    ///
346    /// Returns an error if the file cannot be read or parsed.
347    pub fn load_from_file(path: &std::path::Path) -> Result<Self> {
348        let data = std::fs::read_to_string(path).map_err(|e| {
349            TranscodeError::IoError(format!(
350                "Cannot read profile from '{}': {e}",
351                path.display()
352            ))
353        })?;
354        Self::from_json(&data)
355    }
356}
357
358// ─── Tests ────────────────────────────────────────────────────────────────────
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use std::env::temp_dir;
364
365    #[test]
366    fn test_profile_new() {
367        let p = TranscodeProfile::new("Test");
368        assert_eq!(p.name, "Test");
369        assert_eq!(p.version, 1);
370        assert!(p.description.is_none());
371        assert!(p.tags.is_empty());
372    }
373
374    #[test]
375    fn test_json_round_trip() {
376        let original = TranscodeProfile::youtube_1080p();
377        let json = original.to_json().expect("serialise");
378        let loaded = TranscodeProfile::from_json(&json).expect("deserialise");
379        assert_eq!(loaded.name, original.name);
380        assert_eq!(loaded.video.codec, original.video.codec);
381        assert_eq!(loaded.video.width, Some(1920));
382        assert_eq!(loaded.audio.codec, "aac");
383        assert_eq!(loaded.container.format, "mp4");
384    }
385
386    #[test]
387    fn test_json_compact() {
388        let p = TranscodeProfile::podcast_opus();
389        let json = p.to_json_compact().expect("compact json");
390        assert!(
391            !json.contains('\n'),
392            "compact json should not contain newlines"
393        );
394    }
395
396    #[test]
397    fn test_invalid_json_returns_error() {
398        let result = TranscodeProfile::from_json("not valid json {{{");
399        assert!(result.is_err());
400    }
401
402    #[test]
403    fn test_podcast_profile() {
404        let p = TranscodeProfile::podcast_opus();
405        assert_eq!(p.audio.codec, "opus");
406        assert_eq!(p.audio.sample_rate, 48_000);
407        assert!(p.audio.normalize);
408        assert_eq!(p.container.format, "ogg");
409    }
410
411    #[test]
412    fn test_av1_archive_profile() {
413        let p = TranscodeProfile::av1_1080p_archive();
414        assert_eq!(p.video.codec, "av1");
415        assert_eq!(p.video.tile_columns, Some(2));
416        assert_eq!(p.video.tile_rows, Some(1));
417        assert!(p.video.row_mt);
418        assert_eq!(p.multi_pass, MultiPassMode::TwoPass);
419    }
420
421    #[test]
422    fn test_save_and_load_file() {
423        let path = temp_dir().join("oximedia_test_profile.json");
424        let original = TranscodeProfile::youtube_1080p().tag("env", "ci");
425        original.save_to_file(&path).expect("save ok");
426
427        let loaded = TranscodeProfile::load_from_file(&path).expect("load ok");
428        assert_eq!(loaded.name, original.name);
429        assert_eq!(loaded.tags, vec![("env".to_string(), "ci".to_string())]);
430
431        std::fs::remove_file(&path).ok();
432    }
433
434    #[test]
435    fn test_tag_builder() {
436        let p = TranscodeProfile::new("Tagged")
437            .tag("author", "ci")
438            .tag("project", "oximedia");
439        assert_eq!(p.tags.len(), 2);
440        assert_eq!(p.tags[0], ("author".into(), "ci".into()));
441    }
442
443    #[test]
444    fn test_profile_description() {
445        let p = TranscodeProfile::new("Desc test").description("A helpful description");
446        assert_eq!(p.description.as_deref(), Some("A helpful description"));
447    }
448
449    #[test]
450    fn test_video_profile_default_codec() {
451        let vp = VideoProfileParams::default();
452        assert_eq!(vp.codec, "h264");
453        assert!(vp.row_mt);
454    }
455
456    #[test]
457    fn test_audio_profile_default_codec() {
458        let ap = AudioProfileParams::default();
459        assert_eq!(ap.codec, "aac");
460        assert_eq!(ap.channels, 2);
461    }
462}