1use serde::{Deserialize, Serialize};
9
10use crate::{LoudnessStandard, MultiPassMode, QualityMode, Result, TranscodeError};
11
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct VideoProfileParams {
17 pub codec: String,
19 pub bitrate_bps: Option<u64>,
21 pub crf: Option<u8>,
23 pub preset: Option<String>,
25 pub profile: Option<String>,
27 pub width: Option<u32>,
29 pub height: Option<u32>,
31 pub frame_rate: Option<(u32, u32)>,
33 pub threads: u32,
35 pub quality_mode: Option<QualityMode>,
37 pub row_mt: bool,
39 pub tile_columns: Option<u8>,
41 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67pub struct AudioProfileParams {
68 pub codec: String,
70 pub bitrate_bps: u64,
72 pub sample_rate: u32,
74 pub channels: u8,
76 pub normalize: bool,
78 pub loudness_standard: Option<LoudnessStandard>,
80 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct ContainerParams {
101 pub format: String,
103 pub mp4_fast_start: bool,
105 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub struct TranscodeProfile {
135 pub name: String,
137 pub description: Option<String>,
139 pub version: u32,
141 pub video: VideoProfileParams,
143 pub audio: AudioProfileParams,
145 pub container: ContainerParams,
147 pub multi_pass: MultiPassMode,
149 pub tags: Vec<(String, String)>,
151}
152
153impl TranscodeProfile {
154 #[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 #[must_use]
171 pub fn description(mut self, desc: impl Into<String>) -> Self {
172 self.description = Some(desc.into());
173 self
174 }
175
176 #[must_use]
178 pub fn video(mut self, params: VideoProfileParams) -> Self {
179 self.video = params;
180 self
181 }
182
183 #[must_use]
185 pub fn audio(mut self, params: AudioProfileParams) -> Self {
186 self.audio = params;
187 self
188 }
189
190 #[must_use]
192 pub fn container(mut self, params: ContainerParams) -> Self {
193 self.container = params;
194 self
195 }
196
197 #[must_use]
199 pub fn multi_pass(mut self, mode: MultiPassMode) -> Self {
200 self.multi_pass = mode;
201 self
202 }
203
204 #[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 #[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 #[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 #[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 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 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 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 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 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#[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}