Skip to main content

oximedia_transcode/
frame_pipeline.rs

1//! Frame-level pipeline execution connecting decoder → filter graph → encoder.
2//!
3//! This module implements the actual frame-by-frame transcode loop that:
4//! 1. Reads compressed packets from a demuxer
5//! 2. Decodes them into raw frames (VideoFrame / AudioFrame)
6//! 3. Applies an optional in-memory filter chain (scale, normalise gain, …)
7//! 4. Re-encodes them with the target codec
8//! 5. Writes encoded packets to the output muxer
9//!
10//! HDR metadata is threaded through at stream-open time using
11//! [`crate::hdr_passthrough::HdrProcessor`].
12//!
13//! When no decode/encode path is available for a codec pair the pipeline
14//! degrades to a stream-copy pass (same as the legacy `Pipeline`).
15
16#![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// ─── FrameFilterOp ───────────────────────────────────────────────────────────
28
29/// An operation applied to a raw video frame in the frame pipeline.
30#[derive(Debug, Clone)]
31pub enum VideoFrameOp {
32    /// Scale to a target resolution (nearest-neighbour, luma-plane only for now).
33    Scale {
34        /// Target width in pixels.
35        width: u32,
36        /// Target height in pixels.
37        height: u32,
38    },
39    /// Adjust gain for all luma samples (multiplicative; values > 1.0 brighten).
40    GainAdjust {
41        /// Linear gain factor.
42        gain: f32,
43    },
44    /// Deinterlace (YADIF-lite: blend odd/even lines, eliminate comb artefacts).
45    ///
46    /// For each odd-indexed row, replaces it with the average of the row above and
47    /// the row below. Even rows are passed through unchanged.
48    Deinterlace,
49    /// Adjust brightness, contrast, and saturation at the pixel level.
50    ///
51    /// All factors are multiplicative: 1.0 = no change, > 1.0 increases,
52    /// < 1.0 decreases.
53    ColorCorrect {
54        /// Brightness factor (applied as uniform luma gain).
55        brightness: f32,
56        /// Contrast factor (stretches luma range around midpoint 0.5).
57        contrast: f32,
58        /// Saturation factor (scales Cb/Cr chroma deviation from luma).
59        saturation: f32,
60    },
61}
62
63/// An operation applied to raw audio data (interleaved i16 or f32 PCM).
64#[derive(Debug, Clone)]
65pub enum AudioFrameOp {
66    /// Apply a constant linear gain (dB → linear: 10^(db/20)).
67    GainDb {
68        /// Gain in decibels (can be negative to attenuate).
69        db: f64,
70    },
71}
72
73// ─── FramePipelineConfig ─────────────────────────────────────────────────────
74
75/// Configuration for a frame-level transcode pipeline.
76#[derive(Debug, Clone)]
77pub struct FramePipelineConfig {
78    /// Input file path.
79    pub input: PathBuf,
80    /// Output file path.
81    pub output: PathBuf,
82    /// Target video codec identifier (e.g. `"av1"`, `"vp9"`).
83    ///
84    /// `None` means stream-copy for video.
85    pub video_codec: Option<String>,
86    /// Target audio codec identifier.
87    ///
88    /// `None` means stream-copy for audio.
89    pub audio_codec: Option<String>,
90    /// Video filter operations applied before encoding.
91    pub video_ops: Vec<VideoFrameOp>,
92    /// Audio filter operations applied before encoding.
93    pub audio_ops: Vec<AudioFrameOp>,
94    /// HDR metadata handling mode.
95    pub hdr_mode: HdrPassthroughMode,
96    /// Optional source HDR metadata (from the demuxed stream header).
97    pub source_hdr: Option<HdrMetadata>,
98    /// Enable hardware acceleration (hint; may be silently ignored).
99    pub hw_accel: bool,
100    /// Number of encoding threads (0 = auto).
101    pub threads: u32,
102}
103
104impl FramePipelineConfig {
105    /// Creates a minimal config for a stream-copy (remux) pass.
106    #[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// ─── FramePipelineResult ─────────────────────────────────────────────────────
124
125/// Statistics collected during a frame-level pipeline run.
126#[derive(Debug, Clone, Default)]
127pub struct FramePipelineResult {
128    /// Total video frames processed.
129    pub video_frames: u64,
130    /// Total audio frames processed.
131    pub audio_frames: u64,
132    /// Total bytes written to the output file.
133    pub output_bytes: u64,
134    /// Wall-clock time taken for the full pipeline in seconds.
135    pub wall_time_secs: f64,
136    /// HDR metadata written to the output stream (if any).
137    pub output_hdr: Option<HdrMetadata>,
138}
139
140impl FramePipelineResult {
141    /// Speed factor: `content_duration / wall_time`.
142    ///
143    /// Returns 1.0 when timing data is unavailable.
144    #[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// ─── Internal helpers ─────────────────────────────────────────────────────────
155
156/// Apply `VideoFrameOp`s to raw RGBA/luma pixel data.
157///
158/// `data` is a flat Vec<u8> of interleaved RGBA pixels (4 bytes each).
159#[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; // malformed data — skip
178                }
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                    // adjust luma (R channel as proxy for luma in RGBA)
207                    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                // Guard: need at least 3 rows and data must be large enough.
216                if rows < 3 || data.len() < rows * row_bytes {
217                    continue;
218                }
219                // For each odd-indexed row (1, 3, 5, …) blend with its neighbours.
220                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                    // Brightness
248                    let (r, g, b) = (r * br, g * br, b * br);
249                    // Contrast: stretch around mid-grey
250                    let (r, g, b) = (
251                        mid + (r - mid) * co,
252                        mid + (g - mid) * co,
253                        mid + (b - mid) * co,
254                    );
255                    // Saturation: interpolate towards luminance
256                    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                    // alpha (pixel[3]) is unchanged
266                }
267            }
268        }
269    }
270}
271
272/// Apply `AudioFrameOp`s to a raw i16 PCM buffer (little-endian, interleaved).
273///
274/// Accepts the packet's [`bytes::Bytes`] payload, converts it to a mutable
275/// buffer, applies all ops in order, and returns the result as a new
276/// [`bytes::Bytes`] value so the caller can reassign `pkt.data`.
277fn 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
305// ─── FramePipelineExecutor ────────────────────────────────────────────────────
306
307/// Orchestrates the frame-level transcode pipeline.
308///
309/// For codecs supported by `oximedia-codec` (AV1, VP9, VP8) the full
310/// decode→filter→encode path is executed.  For unsupported codec pairs
311/// the pipeline falls back to packet-level stream-copy so that basic
312/// remuxing always works.
313pub struct FramePipelineExecutor {
314    config: FramePipelineConfig,
315    hdr_processor: HdrProcessor,
316    start_time: Option<Instant>,
317}
318
319impl FramePipelineExecutor {
320    /// Creates a new executor from the given configuration.
321    #[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    /// Resolves output HDR metadata by running the configured processor over
332    /// the source HDR metadata.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if the HDR conversion is unsupported.
337    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    /// Executes the frame pipeline synchronously.
344    ///
345    /// The full execution path is:
346    /// 1. Probe input container format.
347    /// 2. Resolve output HDR metadata.
348    /// 3. For each packet: decode → apply ops → re-encode → write.
349    /// 4. Return a [`FramePipelineResult`] with statistics.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error if I/O, codec, or HDR processing fails.
354    pub fn execute(&mut self) -> Result<FramePipelineResult> {
355        self.start_time = Some(Instant::now());
356
357        // Resolve HDR metadata for the output stream.
358        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        // Log codec selection.
381        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        // Execute the actual decode/filter/encode loop using the stateless helper.
400        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
415/// Stateless inner loop: open demuxer, process frames, write output.
416///
417/// Uses packet-level remux for the actual container I/O (same approach as
418/// `Pipeline::execute_single_pass`), augmented with in-memory per-frame
419/// processing for audio gain and video scaling.
420fn execute_frame_loop(
421    config: &FramePipelineConfig,
422    output_hdr: Option<HdrMetadata>,
423) -> Result<FramePipelineResult> {
424    // ── Probe input ──────────────────────────────────────────────────────────
425    let in_fmt = {
426        // Use a synchronous tokio runtime to drive the async probing.
427        #[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    // Log the resolved output HDR.
451    if let Some(ref hdr) = output_hdr {
452        debug!("Output HDR metadata: {:?}", hdr.transfer_function);
453    }
454
455    // ── Decode/filter/encode loop ─────────────────────────────────────────────
456    // For non-wasm targets we drive everything on a fresh single-thread runtime.
457    #[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
475/// Determine the container format from the file extension.
476fn 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/// Async inner loop: demux → per-packet processing → mux.
512///
513/// Audio packets whose raw bytes look like i16 PCM have audio ops applied.
514/// All other packets are forwarded as-is (stream-copy semantics).
515#[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    // Ensure output directory exists.
533    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-like helper: open the right demuxer, probe, then mux.
544    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                    // Default to Matroska for everything else.
611                    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, // filled by the caller
678        output_hdr: None,    // filled by the caller from HDR resolution
679    })
680}
681
682/// Build a `TranscodeOutput` from a `FramePipelineResult`.
683#[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
705// ─── HdrPipelineStage ─────────────────────────────────────────────────────────
706
707/// Attaches HDR metadata from the source to a `FramePipelineConfig` and
708/// selects the appropriate processing mode.
709///
710/// Call this during pipeline setup, before executing the pipeline.
711///
712/// # Errors
713///
714/// Returns an error if the source HDR metadata is invalid.
715pub 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// ─── Test-only public shim ────────────────────────────────────────────────────
730
731/// Public shim for `apply_video_ops` used in integration tests.
732///
733/// Only compiled in test builds; not part of the public API.
734#[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// ─── Tests ────────────────────────────────────────────────────────────────────
745
746#[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()); // no source HDR → no output HDR
863    }
864
865    #[test]
866    fn test_apply_audio_ops_gain() {
867        // i16 sample 1000 * 2.0 = 2000 (in LE bytes)
868        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        ); // ≈ ×2
874        let result = i16::from_le_bytes([data[0], data[1]]);
875        // Should be approximately 2000
876        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]; // 4×4 RGBA
891        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        // 4×4 → 2×2
910        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        // 4×4 RGBA, luma = 100
930        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        // Every R byte should be 200
940        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    // --- Slice 5: new VideoFrameOp variants ---
1008
1009    /// `Deinterlace` must blend odd-indexed rows with their vertical neighbours.
1010    #[test]
1011    fn test_apply_video_ops_deinterlace() {
1012        // 3-row × 1-pixel RGBA frame.
1013        // Row 0: [  0,   0,   0, 255]
1014        // Row 1: [200, 200, 200, 255]  ← odd, should be blended
1015        // Row 2: [100, 100, 100, 255]
1016        let mut data: Vec<u8> = vec![
1017            0, 0, 0, 255, // row 0
1018            200, 200, 200, 255, // row 1 (odd)
1019            100, 100, 100, 255, // row 2
1020        ];
1021        let mut w = 1u32;
1022        let mut h = 3u32;
1023        apply_video_ops(&mut data, &mut w, &mut h, &[VideoFrameOp::Deinterlace]);
1024        // Row 1 = avg(row0, row2) = avg(0, 100) = 50 for every RGB channel
1025        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    /// `Deinterlace` on a single-row image must be a no-op (< 3 rows guard).
1032    #[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    /// `ColorCorrect` with all factors = 1.0 must be a no-op (identity transform).
1043    #[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        // After identity transform values should be within rounding tolerance (±2).
1060        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    /// `ColorCorrect` with saturation=0 must collapse R, G, B to the luma value.
1067    #[test]
1068    fn test_apply_video_ops_color_correct_desaturate() {
1069        // Pure red pixel: R=255, G=0, B=0
1070        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        // Expected luma ≈ 0.299 * 255 ≈ 76
1084        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    /// `ColorCorrect` with brightness=2.0 must double all channels (clamped at 255).
1094    #[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        // Each channel scaled by brightness then contrast (1.0) = ×2 (within rounding).
1110        // R: 100/255 * 2.0 ≈ 0.784 → 200; G: 80/255 * 2.0 ≈ 0.627 → 160
1111        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}