1#![allow(clippy::cast_precision_loss)]
17#![allow(clippy::cast_possible_truncation)]
18
19use std::path::PathBuf;
20use std::time::Instant;
21
22use tracing::{debug, info, warn};
23
24use crate::hdr_passthrough::{HdrMetadata, HdrPassthroughMode, HdrProcessor};
25use crate::{Result, TranscodeError, TranscodeOutput};
26
27#[derive(Debug, Clone)]
31pub enum VideoFrameOp {
32 Scale {
34 width: u32,
36 height: u32,
38 },
39 GainAdjust {
41 gain: f32,
43 },
44 Deinterlace,
49 ColorCorrect {
54 brightness: f32,
56 contrast: f32,
58 saturation: f32,
60 },
61}
62
63#[derive(Debug, Clone)]
65pub enum AudioFrameOp {
66 GainDb {
68 db: f64,
70 },
71}
72
73#[derive(Debug, Clone)]
77pub struct FramePipelineConfig {
78 pub input: PathBuf,
80 pub output: PathBuf,
82 pub video_codec: Option<String>,
86 pub audio_codec: Option<String>,
90 pub video_ops: Vec<VideoFrameOp>,
92 pub audio_ops: Vec<AudioFrameOp>,
94 pub hdr_mode: HdrPassthroughMode,
96 pub source_hdr: Option<HdrMetadata>,
98 pub hw_accel: bool,
100 pub threads: u32,
102}
103
104impl FramePipelineConfig {
105 #[must_use]
107 pub fn remux(input: impl Into<PathBuf>, output: impl Into<PathBuf>) -> Self {
108 Self {
109 input: input.into(),
110 output: output.into(),
111 video_codec: None,
112 audio_codec: None,
113 video_ops: Vec::new(),
114 audio_ops: Vec::new(),
115 hdr_mode: HdrPassthroughMode::Passthrough,
116 source_hdr: None,
117 hw_accel: true,
118 threads: 0,
119 }
120 }
121}
122
123#[derive(Debug, Clone, Default)]
127pub struct FramePipelineResult {
128 pub video_frames: u64,
130 pub audio_frames: u64,
132 pub output_bytes: u64,
134 pub wall_time_secs: f64,
136 pub output_hdr: Option<HdrMetadata>,
138}
139
140impl FramePipelineResult {
141 #[must_use]
145 pub fn speed_factor(&self, content_duration_secs: f64) -> f64 {
146 if self.wall_time_secs > 0.0 && content_duration_secs > 0.0 {
147 content_duration_secs / self.wall_time_secs
148 } else {
149 1.0
150 }
151 }
152}
153
154#[allow(dead_code)]
160fn apply_video_ops(data: &mut Vec<u8>, width: &mut u32, height: &mut u32, ops: &[VideoFrameOp]) {
161 for op in ops {
162 match op {
163 VideoFrameOp::Scale {
164 width: dw,
165 height: dh,
166 } => {
167 if *dw == 0 || *dh == 0 || (*dw == *width && *dh == *height) {
168 continue;
169 }
170 let src_w = *width;
171 let src_h = *height;
172 let dst_w = *dw;
173 let dst_h = *dh;
174
175 let expected_src = (src_w * src_h * 4) as usize;
176 if data.len() < expected_src {
177 continue; }
179
180 let mut dst = vec![0u8; (dst_w * dst_h * 4) as usize];
181 for dy in 0..dst_h {
182 for dx in 0..dst_w {
183 let sx = (f64::from(dx) * f64::from(src_w) / f64::from(dst_w)) as u32;
184 let sy = (f64::from(dy) * f64::from(src_h) / f64::from(dst_h)) as u32;
185 let src_idx = ((sy * src_w + sx) * 4) as usize;
186 let dst_idx = ((dy * dst_w + dx) * 4) as usize;
187 if src_idx + 3 < data.len() {
188 dst[dst_idx] = data[src_idx];
189 dst[dst_idx + 1] = data[src_idx + 1];
190 dst[dst_idx + 2] = data[src_idx + 2];
191 dst[dst_idx + 3] = data[src_idx + 3];
192 }
193 }
194 }
195 *data = dst;
196 *width = dst_w;
197 *height = dst_h;
198 }
199
200 VideoFrameOp::GainAdjust { gain } => {
201 let g = *gain;
202 if (g - 1.0).abs() < f32::EPSILON {
203 continue;
204 }
205 for byte in data.iter_mut().step_by(4) {
206 let v = (*byte as f32 * g).clamp(0.0, 255.0) as u8;
208 *byte = v;
209 }
210 }
211
212 VideoFrameOp::Deinterlace => {
213 let row_bytes = (*width as usize) * 4;
214 let rows = *height as usize;
215 if rows < 3 || data.len() < rows * row_bytes {
217 continue;
218 }
219 let mut y = 1usize;
221 while y < rows - 1 {
222 let prev_start = (y - 1) * row_bytes;
223 let curr_start = y * row_bytes;
224 let next_start = (y + 1) * row_bytes;
225 for x in 0..row_bytes {
226 let blended =
227 ((data[prev_start + x] as u16 + data[next_start + x] as u16) / 2) as u8;
228 data[curr_start + x] = blended;
229 }
230 y += 2;
231 }
232 }
233
234 VideoFrameOp::ColorCorrect {
235 brightness,
236 contrast,
237 saturation,
238 } => {
239 let br = *brightness;
240 let co = *contrast;
241 let sa = *saturation;
242 let mid = 0.5_f32;
243 for pixel in data.chunks_exact_mut(4) {
244 let r = pixel[0] as f32 / 255.0;
245 let g = pixel[1] as f32 / 255.0;
246 let b = pixel[2] as f32 / 255.0;
247 let (r, g, b) = (r * br, g * br, b * br);
249 let (r, g, b) = (
251 mid + (r - mid) * co,
252 mid + (g - mid) * co,
253 mid + (b - mid) * co,
254 );
255 let luma = 0.299 * r + 0.587 * g + 0.114 * b;
257 let (r, g, b) = (
258 luma + (r - luma) * sa,
259 luma + (g - luma) * sa,
260 luma + (b - luma) * sa,
261 );
262 pixel[0] = (r.clamp(0.0, 1.0) * 255.0) as u8;
263 pixel[1] = (g.clamp(0.0, 1.0) * 255.0) as u8;
264 pixel[2] = (b.clamp(0.0, 1.0) * 255.0) as u8;
265 }
267 }
268 }
269 }
270}
271
272fn apply_audio_ops(data: bytes::Bytes, ops: &[AudioFrameOp]) -> bytes::Bytes {
278 if ops.is_empty() {
279 return data;
280 }
281 let mut buf: Vec<u8> = data.into();
282 for op in ops {
283 match op {
284 AudioFrameOp::GainDb { db } => {
285 if db.abs() < 0.001 {
286 continue;
287 }
288 let linear = 10f64.powf(*db / 20.0) as f32;
289 let n_samples = buf.len() / 2;
290 for i in 0..n_samples {
291 let lo = buf[i * 2];
292 let hi = buf[i * 2 + 1];
293 let sample = i16::from_le_bytes([lo, hi]) as f32;
294 let clamped = (sample * linear).clamp(i16::MIN as f32, i16::MAX as f32) as i16;
295 let bytes = clamped.to_le_bytes();
296 buf[i * 2] = bytes[0];
297 buf[i * 2 + 1] = bytes[1];
298 }
299 }
300 }
301 }
302 bytes::Bytes::from(buf)
303}
304
305pub struct FramePipelineExecutor {
314 config: FramePipelineConfig,
315 hdr_processor: HdrProcessor,
316 start_time: Option<Instant>,
317}
318
319impl FramePipelineExecutor {
320 #[must_use]
322 pub fn new(config: FramePipelineConfig) -> Self {
323 let hdr_processor = HdrProcessor::new(config.hdr_mode.clone());
324 Self {
325 config,
326 hdr_processor,
327 start_time: None,
328 }
329 }
330
331 pub fn resolve_output_hdr(&self) -> Result<Option<HdrMetadata>> {
338 self.hdr_processor
339 .process(self.config.source_hdr.as_ref())
340 .map_err(|e| TranscodeError::CodecError(format!("HDR processing failed: {e}")))
341 }
342
343 pub fn execute(&mut self) -> Result<FramePipelineResult> {
355 self.start_time = Some(Instant::now());
356
357 let output_hdr = self.resolve_output_hdr()?;
359
360 if let Some(ref hdr) = output_hdr {
361 if hdr.is_hdr() {
362 info!(
363 "Frame pipeline: output will carry HDR metadata (tf={:?})",
364 hdr.transfer_function
365 );
366 }
367 } else if self
368 .config
369 .source_hdr
370 .as_ref()
371 .map(|h| h.is_hdr())
372 .unwrap_or(false)
373 {
374 info!(
375 "Frame pipeline: HDR metadata stripped from output (mode={:?})",
376 self.config.hdr_mode
377 );
378 }
379
380 let video_codec = self
382 .config
383 .video_codec
384 .as_deref()
385 .unwrap_or("(stream-copy)");
386 let audio_codec = self
387 .config
388 .audio_codec
389 .as_deref()
390 .unwrap_or("(stream-copy)");
391 info!(
392 "Frame pipeline: {} → {} [video: {} audio: {}]",
393 self.config.input.display(),
394 self.config.output.display(),
395 video_codec,
396 audio_codec
397 );
398
399 let result = execute_frame_loop(&self.config, output_hdr)?;
401
402 let elapsed = self.start_time.map_or(0.0, |t| t.elapsed().as_secs_f64());
403 info!(
404 "Frame pipeline complete: {} video frames, {} audio frames in {:.2}s",
405 result.video_frames, result.audio_frames, elapsed
406 );
407
408 Ok(FramePipelineResult {
409 wall_time_secs: elapsed,
410 ..result
411 })
412 }
413}
414
415fn execute_frame_loop(
421 config: &FramePipelineConfig,
422 output_hdr: Option<HdrMetadata>,
423) -> Result<FramePipelineResult> {
424 let in_fmt = {
426 #[cfg(not(target_arch = "wasm32"))]
428 {
429 let rt = tokio::runtime::Builder::new_current_thread()
430 .enable_all()
431 .build()
432 .map_err(|e| TranscodeError::PipelineError(e.to_string()))?;
433 rt.block_on(probe_input_format(&config.input))?
434 }
435 #[cfg(target_arch = "wasm32")]
436 {
437 return Err(TranscodeError::Unsupported(
438 "Frame pipeline is not supported on wasm32".into(),
439 ));
440 }
441 };
442
443 let out_fmt = out_format_from_path(&config.output);
444
445 debug!(
446 "Frame pipeline formats: input={:?} output={:?}",
447 in_fmt, out_fmt
448 );
449
450 if let Some(ref hdr) = output_hdr {
452 debug!("Output HDR metadata: {:?}", hdr.transfer_function);
453 }
454
455 #[cfg(not(target_arch = "wasm32"))]
458 {
459 let cfg = config.clone();
460 let rt = tokio::runtime::Builder::new_current_thread()
461 .enable_all()
462 .build()
463 .map_err(|e| TranscodeError::PipelineError(e.to_string()))?;
464
465 rt.block_on(async move { run_async_frame_loop(&cfg, in_fmt, out_fmt).await })
466 }
467 #[cfg(target_arch = "wasm32")]
468 {
469 Err(TranscodeError::Unsupported(
470 "Frame pipeline not available on wasm32".into(),
471 ))
472 }
473}
474
475fn out_format_from_path(path: &std::path::Path) -> oximedia_container::ContainerFormat {
477 use oximedia_container::ContainerFormat;
478 match path
479 .extension()
480 .and_then(|e| e.to_str())
481 .map(str::to_lowercase)
482 .as_deref()
483 {
484 Some("ogg") | Some("oga") | Some("opus") => ContainerFormat::Ogg,
485 Some("flac") => ContainerFormat::Flac,
486 Some("wav") => ContainerFormat::Wav,
487 _ => ContainerFormat::Matroska,
488 }
489}
490
491#[cfg(not(target_arch = "wasm32"))]
492async fn probe_input_format(path: &std::path::Path) -> Result<oximedia_container::ContainerFormat> {
493 use oximedia_container::probe_format;
494 use oximedia_io::{FileSource, MediaSource};
495
496 let mut source = FileSource::open(path)
497 .await
498 .map_err(|e| TranscodeError::IoError(e.to_string()))?;
499
500 let mut buf = vec![0u8; 16 * 1024];
501 let n = source
502 .read(&mut buf)
503 .await
504 .map_err(|e| TranscodeError::IoError(e.to_string()))?;
505 buf.truncate(n);
506
507 let result = probe_format(&buf).map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
508 Ok(result.format)
509}
510
511#[cfg(not(target_arch = "wasm32"))]
516async fn run_async_frame_loop(
517 config: &FramePipelineConfig,
518 in_fmt: oximedia_container::ContainerFormat,
519 out_fmt: oximedia_container::ContainerFormat,
520) -> Result<FramePipelineResult> {
521 use oximedia_container::{
522 demux::{Demuxer, FlacDemuxer, MatroskaDemuxer, OggDemuxer, WavDemuxer},
523 mux::{MatroskaMuxer, MuxerConfig, OggMuxer},
524 ContainerFormat, Muxer,
525 };
526 use oximedia_io::FileSource;
527
528 let mut video_frames = 0u64;
529 let mut audio_frames = 0u64;
530 let mut output_bytes = 0u64;
531
532 if let Some(parent) = config.output.parent() {
534 if !parent.as_os_str().is_empty() && !parent.exists() {
535 tokio::fs::create_dir_all(parent)
536 .await
537 .map_err(|e| TranscodeError::IoError(e.to_string()))?;
538 }
539 }
540
541 let mux_cfg = MuxerConfig::new().with_writing_app("OxiMedia-FramePipeline");
542
543 macro_rules! run_with_demuxer {
545 ($demuxer_type:expr) => {{
546 let source = FileSource::open(&config.input)
547 .await
548 .map_err(|e| TranscodeError::IoError(e.to_string()))?;
549 let mut demuxer = $demuxer_type(source);
550 demuxer
551 .probe()
552 .await
553 .map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
554
555 let streams = demuxer.streams().to_vec();
556 if streams.is_empty() {
557 return Err(TranscodeError::ContainerError("No streams in input".into()));
558 }
559
560 let audio_stream_indices: Vec<usize> = streams
561 .iter()
562 .filter(|s| s.is_audio())
563 .map(|s| s.index)
564 .collect();
565
566 match out_fmt {
567 ContainerFormat::Ogg => {
568 let sink = FileSource::create(&config.output)
569 .await
570 .map_err(|e| TranscodeError::IoError(e.to_string()))?;
571 let mut muxer = OggMuxer::new(sink, mux_cfg.clone());
572 for s in &streams {
573 muxer
574 .add_stream(s.clone())
575 .map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
576 }
577 muxer
578 .write_header()
579 .await
580 .map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
581
582 loop {
583 match demuxer.read_packet().await {
584 Ok(mut pkt) => {
585 if pkt.should_discard() {
586 continue;
587 }
588 if audio_stream_indices.contains(&pkt.stream_index) {
589 pkt.data = apply_audio_ops(pkt.data.clone(), &config.audio_ops);
590 audio_frames += 1;
591 } else {
592 video_frames += 1;
593 }
594 output_bytes += pkt.data.len() as u64;
595 muxer
596 .write_packet(&pkt)
597 .await
598 .map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
599 }
600 Err(e) if e.is_eof() => break,
601 Err(e) => return Err(TranscodeError::ContainerError(e.to_string())),
602 }
603 }
604 muxer
605 .write_trailer()
606 .await
607 .map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
608 }
609 _ => {
610 let sink = FileSource::create(&config.output)
612 .await
613 .map_err(|e| TranscodeError::IoError(e.to_string()))?;
614 let mut muxer = MatroskaMuxer::new(sink, mux_cfg.clone());
615 for s in &streams {
616 muxer
617 .add_stream(s.clone())
618 .map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
619 }
620 muxer
621 .write_header()
622 .await
623 .map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
624
625 loop {
626 match demuxer.read_packet().await {
627 Ok(mut pkt) => {
628 if pkt.should_discard() {
629 continue;
630 }
631 if audio_stream_indices.contains(&pkt.stream_index) {
632 pkt.data = apply_audio_ops(pkt.data.clone(), &config.audio_ops);
633 audio_frames += 1;
634 } else {
635 video_frames += 1;
636 }
637 output_bytes += pkt.data.len() as u64;
638 muxer
639 .write_packet(&pkt)
640 .await
641 .map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
642 }
643 Err(e) if e.is_eof() => break,
644 Err(e) => return Err(TranscodeError::ContainerError(e.to_string())),
645 }
646 }
647 muxer
648 .write_trailer()
649 .await
650 .map_err(|e| TranscodeError::ContainerError(e.to_string()))?;
651 }
652 }
653 }};
654 }
655
656 match in_fmt {
657 ContainerFormat::Matroska => run_with_demuxer!(|s| MatroskaDemuxer::new(s)),
658 ContainerFormat::Ogg => run_with_demuxer!(|s| OggDemuxer::new(s)),
659 ContainerFormat::Wav => run_with_demuxer!(|s| WavDemuxer::new(s)),
660 ContainerFormat::Flac => run_with_demuxer!(|s| FlacDemuxer::new(s)),
661 other => {
662 warn!(
663 "Frame pipeline: unsupported input format {:?}, cannot execute",
664 other
665 );
666 return Err(TranscodeError::ContainerError(format!(
667 "Unsupported input container for frame pipeline: {:?}",
668 other
669 )));
670 }
671 }
672
673 Ok(FramePipelineResult {
674 video_frames,
675 audio_frames,
676 output_bytes,
677 wall_time_secs: 0.0, output_hdr: None, })
680}
681
682#[must_use]
684pub fn pipeline_result_to_output(
685 result: &FramePipelineResult,
686 output_path: &std::path::Path,
687 file_size: u64,
688 content_duration_secs: f64,
689) -> TranscodeOutput {
690 let speed = result.speed_factor(content_duration_secs);
691 TranscodeOutput {
692 output_path: output_path
693 .to_str()
694 .map(String::from)
695 .unwrap_or_else(|| output_path.display().to_string()),
696 file_size,
697 duration: content_duration_secs,
698 video_bitrate: 0,
699 audio_bitrate: 0,
700 encoding_time: result.wall_time_secs,
701 speed_factor: speed,
702 }
703}
704
705pub fn wire_hdr_into_pipeline(
716 config: &mut FramePipelineConfig,
717 source_hdr: Option<HdrMetadata>,
718 mode: HdrPassthroughMode,
719) -> Result<()> {
720 if let Some(ref hdr) = source_hdr {
721 hdr.validate()
722 .map_err(|e| TranscodeError::CodecError(format!("Source HDR invalid: {e}")))?;
723 }
724 config.source_hdr = source_hdr;
725 config.hdr_mode = mode;
726 Ok(())
727}
728
729#[cfg(test)]
735pub fn apply_video_ops_pub(
736 data: &mut Vec<u8>,
737 width: &mut u32,
738 height: &mut u32,
739 ops: &[VideoFrameOp],
740) {
741 apply_video_ops(data, width, height, ops);
742}
743
744#[cfg(test)]
747mod tests {
748 use super::*;
749 use crate::hdr_passthrough::{
750 ColourPrimaries, ContentLightLevel, HdrMetadata, MasteringDisplay, TransferFunction,
751 };
752
753 fn tmp_in() -> PathBuf {
754 std::env::temp_dir().join("oximedia-transcode-frame-in.mkv")
755 }
756
757 fn tmp_out() -> PathBuf {
758 std::env::temp_dir().join("oximedia-transcode-frame-out.mkv")
759 }
760
761 #[test]
762 fn test_frame_pipeline_config_remux() {
763 let ti = tmp_in();
764 let cfg = FramePipelineConfig::remux(ti.clone(), tmp_out());
765 assert_eq!(cfg.input, ti);
766 assert!(cfg.video_codec.is_none());
767 assert!(cfg.audio_codec.is_none());
768 assert!(cfg.video_ops.is_empty());
769 }
770
771 #[test]
772 fn test_wire_hdr_passthrough() {
773 let mut cfg = FramePipelineConfig::remux(tmp_in(), tmp_out());
774 let hdr = HdrMetadata::hdr10(
775 MasteringDisplay::p3_d65_1000nit(),
776 ContentLightLevel::hdr10_default(),
777 );
778 assert!(wire_hdr_into_pipeline(
779 &mut cfg,
780 Some(hdr.clone()),
781 HdrPassthroughMode::Passthrough
782 )
783 .is_ok());
784 assert!(cfg.source_hdr.is_some());
785 assert_eq!(cfg.hdr_mode, HdrPassthroughMode::Passthrough);
786 }
787
788 #[test]
789 fn test_wire_hdr_strip() {
790 let mut cfg = FramePipelineConfig::remux(tmp_in(), tmp_out());
791 let hdr = HdrMetadata::hlg();
792 assert!(wire_hdr_into_pipeline(&mut cfg, Some(hdr), HdrPassthroughMode::Strip).is_ok());
793 }
794
795 #[test]
796 fn test_wire_hdr_convert() {
797 let mut cfg = FramePipelineConfig::remux(tmp_in(), tmp_out());
798 let hdr = HdrMetadata::hdr10(
799 MasteringDisplay::p3_d65_1000nit(),
800 ContentLightLevel::hdr10_default(),
801 );
802 let mode = HdrPassthroughMode::Convert {
803 target_tf: TransferFunction::Hlg,
804 target_primaries: ColourPrimaries::Bt2020,
805 };
806 assert!(wire_hdr_into_pipeline(&mut cfg, Some(hdr), mode).is_ok());
807 }
808
809 #[test]
810 fn test_resolve_output_hdr_passthrough() {
811 let mut cfg = FramePipelineConfig::remux(tmp_in(), tmp_out());
812 let hdr = HdrMetadata::hlg();
813 wire_hdr_into_pipeline(&mut cfg, Some(hdr.clone()), HdrPassthroughMode::Passthrough)
814 .expect("wire ok");
815 let exec = FramePipelineExecutor::new(cfg);
816 let out = exec.resolve_output_hdr().expect("resolve ok");
817 assert!(out.is_some());
818 assert_eq!(
819 out.as_ref().and_then(|m| m.transfer_function),
820 Some(TransferFunction::Hlg)
821 );
822 }
823
824 #[test]
825 fn test_resolve_output_hdr_strip() {
826 let mut cfg = FramePipelineConfig::remux(tmp_in(), tmp_out());
827 let hdr = HdrMetadata::hdr10(
828 MasteringDisplay::p3_d65_1000nit(),
829 ContentLightLevel::hdr10_default(),
830 );
831 wire_hdr_into_pipeline(&mut cfg, Some(hdr), HdrPassthroughMode::Strip).expect("wire ok");
832 let exec = FramePipelineExecutor::new(cfg);
833 let out = exec.resolve_output_hdr().expect("resolve ok");
834 assert!(out.is_none());
835 }
836
837 #[test]
838 fn test_resolve_output_hdr_convert_pq_to_hlg() {
839 let mut cfg = FramePipelineConfig::remux(tmp_in(), tmp_out());
840 let hdr = HdrMetadata::hdr10(
841 MasteringDisplay::p3_d65_1000nit(),
842 ContentLightLevel::hdr10_default(),
843 );
844 let mode = HdrPassthroughMode::Convert {
845 target_tf: TransferFunction::Hlg,
846 target_primaries: ColourPrimaries::Bt2020,
847 };
848 wire_hdr_into_pipeline(&mut cfg, Some(hdr), mode).expect("wire ok");
849 let exec = FramePipelineExecutor::new(cfg);
850 let out = exec.resolve_output_hdr().expect("resolve ok");
851 assert_eq!(
852 out.as_ref().and_then(|m| m.transfer_function),
853 Some(TransferFunction::Hlg)
854 );
855 }
856
857 #[test]
858 fn test_resolve_output_hdr_none_source() {
859 let cfg = FramePipelineConfig::remux(tmp_in(), tmp_out());
860 let exec = FramePipelineExecutor::new(cfg);
861 let out = exec.resolve_output_hdr().expect("resolve ok");
862 assert!(out.is_none()); }
864
865 #[test]
866 fn test_apply_audio_ops_gain() {
867 let sample: i16 = 1000;
869 let raw = vec![sample.to_le_bytes()[0], sample.to_le_bytes()[1]];
870 let data = apply_audio_ops(
871 bytes::Bytes::from(raw),
872 &[AudioFrameOp::GainDb { db: 6.0206 }],
873 ); let result = i16::from_le_bytes([data[0], data[1]]);
875 assert!(result > 1900 && result < 2100, "result was {result}");
877 }
878
879 #[test]
880 fn test_apply_audio_ops_no_op() {
881 let sample: i16 = 500;
882 let raw = vec![sample.to_le_bytes()[0], sample.to_le_bytes()[1]];
883 let data = apply_audio_ops(bytes::Bytes::from(raw), &[AudioFrameOp::GainDb { db: 0.0 }]);
884 let result = i16::from_le_bytes([data[0], data[1]]);
885 assert_eq!(result, 500);
886 }
887
888 #[test]
889 fn test_apply_video_ops_scale_identity() {
890 let mut data = vec![255u8; 4 * 4 * 4]; let mut w = 4u32;
892 let mut h = 4u32;
893 apply_video_ops(
894 &mut data,
895 &mut w,
896 &mut h,
897 &[VideoFrameOp::Scale {
898 width: 4,
899 height: 4,
900 }],
901 );
902 assert_eq!(w, 4);
903 assert_eq!(h, 4);
904 assert_eq!(data.len(), 4 * 4 * 4);
905 }
906
907 #[test]
908 fn test_apply_video_ops_scale_down() {
909 let mut data = vec![128u8; 4 * 4 * 4];
911 let mut w = 4u32;
912 let mut h = 4u32;
913 apply_video_ops(
914 &mut data,
915 &mut w,
916 &mut h,
917 &[VideoFrameOp::Scale {
918 width: 2,
919 height: 2,
920 }],
921 );
922 assert_eq!(w, 2);
923 assert_eq!(h, 2);
924 assert_eq!(data.len(), 2 * 2 * 4);
925 }
926
927 #[test]
928 fn test_apply_video_ops_gain() {
929 let mut data: Vec<u8> = (0..16).flat_map(|_| vec![100u8, 0, 0, 255]).collect();
931 let mut w = 4u32;
932 let mut h = 4u32;
933 apply_video_ops(
934 &mut data,
935 &mut w,
936 &mut h,
937 &[VideoFrameOp::GainAdjust { gain: 2.0 }],
938 );
939 assert_eq!(data[0], 200);
941 assert_eq!(data[4], 200);
942 }
943
944 #[test]
945 fn test_pipeline_result_speed_factor() {
946 let r = FramePipelineResult {
947 wall_time_secs: 10.0,
948 ..Default::default()
949 };
950 assert!((r.speed_factor(30.0) - 3.0).abs() < 1e-9);
951 }
952
953 #[test]
954 fn test_pipeline_result_speed_factor_zero_time() {
955 let r = FramePipelineResult::default();
956 assert!((r.speed_factor(30.0) - 1.0).abs() < 1e-9);
957 }
958
959 #[test]
960 fn test_out_format_from_path() {
961 use oximedia_container::ContainerFormat;
962 assert!(matches!(
963 out_format_from_path(std::path::Path::new("out.ogg")),
964 ContainerFormat::Ogg
965 ));
966 assert!(matches!(
967 out_format_from_path(std::path::Path::new("out.mkv")),
968 ContainerFormat::Matroska
969 ));
970 assert!(matches!(
971 out_format_from_path(std::path::Path::new("out.webm")),
972 ContainerFormat::Matroska
973 ));
974 }
975
976 #[test]
977 fn test_pipeline_result_to_output() {
978 let result = FramePipelineResult {
979 video_frames: 100,
980 audio_frames: 50,
981 output_bytes: 1_000_000,
982 wall_time_secs: 5.0,
983 output_hdr: None,
984 };
985 let to = tmp_out();
986 let out = pipeline_result_to_output(&result, &to, 1_000_000, 30.0);
987 assert_eq!(out.file_size, 1_000_000);
988 assert!((out.speed_factor - 6.0).abs() < 1e-9);
989 assert_eq!(out.output_path, to.to_string_lossy().as_ref());
990 }
991
992 #[test]
993 fn test_wire_hdr_inject() {
994 let mut cfg = FramePipelineConfig::remux(tmp_in(), tmp_out());
995 let injected = HdrMetadata::hlg();
996 let mode = HdrPassthroughMode::Inject(injected.clone());
997 assert!(wire_hdr_into_pipeline(&mut cfg, None, mode).is_ok());
998 let exec = FramePipelineExecutor::new(cfg);
999 let out = exec.resolve_output_hdr().expect("inject ok");
1000 assert!(out.is_some());
1001 assert_eq!(
1002 out.as_ref().and_then(|m| m.transfer_function),
1003 Some(TransferFunction::Hlg)
1004 );
1005 }
1006
1007 #[test]
1011 fn test_apply_video_ops_deinterlace() {
1012 let mut data: Vec<u8> = vec![
1017 0, 0, 0, 255, 200, 200, 200, 255, 100, 100, 100, 255, ];
1021 let mut w = 1u32;
1022 let mut h = 3u32;
1023 apply_video_ops(&mut data, &mut w, &mut h, &[VideoFrameOp::Deinterlace]);
1024 assert_eq!(data[4], 50, "row1 R blended to 50");
1026 assert_eq!(data[5], 50, "row1 G blended to 50");
1027 assert_eq!(data[6], 50, "row1 B blended to 50");
1028 assert_eq!(data[7], 255, "row1 alpha unchanged");
1029 }
1030
1031 #[test]
1033 fn test_apply_video_ops_deinterlace_too_small() {
1034 let original: Vec<u8> = vec![128, 64, 32, 255];
1035 let mut data = original.clone();
1036 let mut w = 1u32;
1037 let mut h = 1u32;
1038 apply_video_ops(&mut data, &mut w, &mut h, &[VideoFrameOp::Deinterlace]);
1039 assert_eq!(data, original, "single-row frame must not be modified");
1040 }
1041
1042 #[test]
1044 fn test_apply_video_ops_color_correct_identity() {
1045 let mut data: Vec<u8> = vec![100, 150, 200, 255];
1046 let orig = data.clone();
1047 let mut w = 1u32;
1048 let mut h = 1u32;
1049 apply_video_ops(
1050 &mut data,
1051 &mut w,
1052 &mut h,
1053 &[VideoFrameOp::ColorCorrect {
1054 brightness: 1.0,
1055 contrast: 1.0,
1056 saturation: 1.0,
1057 }],
1058 );
1059 assert!((data[0] as i16 - orig[0] as i16).abs() <= 2, "R identity");
1061 assert!((data[1] as i16 - orig[1] as i16).abs() <= 2, "G identity");
1062 assert!((data[2] as i16 - orig[2] as i16).abs() <= 2, "B identity");
1063 assert_eq!(data[3], 255, "alpha unchanged");
1064 }
1065
1066 #[test]
1068 fn test_apply_video_ops_color_correct_desaturate() {
1069 let mut data: Vec<u8> = vec![255, 0, 0, 255];
1071 let mut w = 1u32;
1072 let mut h = 1u32;
1073 apply_video_ops(
1074 &mut data,
1075 &mut w,
1076 &mut h,
1077 &[VideoFrameOp::ColorCorrect {
1078 brightness: 1.0,
1079 contrast: 1.0,
1080 saturation: 0.0,
1081 }],
1082 );
1083 let expected_u8 = (0.299_f32 * 255.0_f32) as u8;
1085 assert!(
1086 (data[0] as i16 - expected_u8 as i16).abs() <= 2,
1087 "R should be ~luma ({expected_u8}), got {}",
1088 data[0]
1089 );
1090 assert_eq!(data[3], 255, "alpha unchanged");
1091 }
1092
1093 #[test]
1095 fn test_apply_video_ops_color_correct_brightness_double() {
1096 let mut data: Vec<u8> = vec![100, 80, 60, 200];
1097 let mut w = 1u32;
1098 let mut h = 1u32;
1099 apply_video_ops(
1100 &mut data,
1101 &mut w,
1102 &mut h,
1103 &[VideoFrameOp::ColorCorrect {
1104 brightness: 2.0,
1105 contrast: 1.0,
1106 saturation: 1.0,
1107 }],
1108 );
1109 assert!(
1112 (data[0] as i16 - 200).abs() <= 3,
1113 "R doubled: expected ~200, got {}",
1114 data[0]
1115 );
1116 assert_eq!(data[3], 200, "alpha unchanged");
1117 }
1118}