use anyhow::{Context, Result};
use colored::Colorize;
use std::path::PathBuf;
pub struct RestoreAudioOptions {
pub input: PathBuf,
pub output: PathBuf,
pub mode: String,
pub sample_rate: Option<u32>,
pub declip: bool,
pub decrackle: bool,
pub dehum: bool,
pub denoise: bool,
pub raw: bool,
}
pub struct RestoreVideoOptions {
pub input: PathBuf,
pub output: PathBuf,
pub mode: String,
pub width: Option<u32>,
pub height: Option<u32>,
}
pub struct RestoreAnalyzeOptions {
pub input: PathBuf,
pub analysis_type: String,
}
pub struct RestoreBatchOptions {
pub input_dir: PathBuf,
pub output_dir: PathBuf,
pub mode: String,
pub extension: Option<String>,
}
pub struct RestoreCompareOptions {
pub original: PathBuf,
pub restored: PathBuf,
}
fn decode_audio_file(data: &[u8], ext: &str) -> Result<(Vec<f32>, u32)> {
match ext {
"wav" | "wave" => {
use oximedia_audio::wav::WavReader;
let mut reader =
WavReader::new(std::io::Cursor::new(data)).context("Failed to open WAV file")?;
let spec = reader.spec();
let detected_rate = spec.sample_rate;
let mut samples = reader
.read_samples_f32()
.context("Failed to decode WAV samples")?;
if spec.channels > 1 {
let ch = spec.channels as usize;
samples = samples
.chunks(ch)
.map(|c| c.iter().sum::<f32>() / ch as f32)
.collect();
}
Ok((samples, detected_rate))
}
"flac" => {
Err(anyhow::anyhow!(
"FLAC decoding is not yet available in the restore pipeline. \
Convert to WAV first (e.g. `ffmpeg -i input.flac output.wav`) \
or use --raw to treat the input as raw PCM float32 LE."
))
}
"mp3" => {
use oximedia_audio::mp3::Mp3Decoder;
use oximedia_audio::{AudioBuffer, AudioDecoder};
let mut decoder = Mp3Decoder::new();
decoder
.send_packet(data, 0)
.map_err(|e| anyhow::anyhow!("MP3 send_packet failed: {e}"))?;
let mut all_samples: Vec<f32> = Vec::new();
let mut detected_rate = 44100u32;
loop {
match decoder.receive_frame() {
Ok(Some(frame)) => {
detected_rate = frame.sample_rate;
let channels = frame.channels.count();
let raw_bytes: Vec<u8> = match &frame.samples {
AudioBuffer::Interleaved(b) => b.to_vec(),
AudioBuffer::Planar(planes) => {
if planes.is_empty() {
Vec::new()
} else {
let per_ch = planes[0].len();
let mut interleaved = Vec::with_capacity(per_ch * planes.len());
for i in 0..(per_ch / 4) {
for plane in planes {
if i * 4 + 3 < plane.len() {
interleaved
.extend_from_slice(&plane[i * 4..i * 4 + 4]);
}
}
}
interleaved
}
}
};
let frame_samples: Vec<f32> = raw_bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
if channels > 1 {
all_samples.extend(
frame_samples
.chunks(channels)
.map(|c| c.iter().sum::<f32>() / channels as f32),
);
} else {
all_samples.extend_from_slice(&frame_samples);
}
}
Ok(None) => break,
Err(e) => {
let e_str = format!("{e}");
if e_str.contains("NeedMoreData") || e_str.contains("need") {
break;
}
return Err(anyhow::anyhow!("MP3 decode error: {e}"));
}
}
}
if all_samples.is_empty() {
return Err(anyhow::anyhow!(
"MP3 decoding produced no samples — the file may be too short or corrupt."
));
}
Ok((all_samples, detected_rate))
}
other => Err(anyhow::anyhow!(
"Unsupported audio format '.{}'. Supported formats: wav, mp3. \
Use --raw to treat the input as raw PCM float32 LE, or convert \
to WAV before restoring.",
other
)),
}
}
pub async fn run_restore_audio(opts: RestoreAudioOptions, json_output: bool) -> Result<()> {
use oximedia_restore::presets::{BroadcastCleanup, TapeRestoration, VinylRestoration};
use oximedia_restore::RestoreChain;
let data = std::fs::read(&opts.input)
.with_context(|| format!("Failed to read input: {}", opts.input.display()))?;
let (samples, sample_rate) = if opts.raw {
let sr = opts.sample_rate.unwrap_or(44100);
(bytes_to_f32_samples(&data), sr)
} else {
let ext = opts
.input
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_default();
let (decoded_samples, detected_rate) = decode_audio_file(&data, &ext)?;
let sr = opts.sample_rate.unwrap_or(detected_rate);
(decoded_samples, sr)
};
let mut chain = RestoreChain::new();
match opts.mode.to_lowercase().as_str() {
"vinyl" => {
let mut preset = VinylRestoration::new(sample_rate);
preset.click_removal = true;
preset.crackle_removal = opts.decrackle;
preset.hum_removal = opts.dehum;
chain.add_preset(preset);
}
"tape" => {
let preset = TapeRestoration::new(sample_rate);
chain.add_preset(preset);
}
"broadcast" => {
let preset = BroadcastCleanup::new(sample_rate);
chain.add_preset(preset);
}
"archival" => {
let vinyl = VinylRestoration::new(sample_rate);
chain.add_preset(vinyl);
let tape = TapeRestoration::new(sample_rate);
chain.add_preset(tape);
}
_ => {
use oximedia_restore::dc::DcRemover;
use oximedia_restore::RestorationStep;
chain.add_step(RestorationStep::DcRemoval(DcRemover::new(
10.0,
sample_rate,
)));
if opts.declip {
use oximedia_restore::clip::{
BasicDeclipper, ClipDetector, ClipDetectorConfig, DeclipConfig,
};
chain.add_step(RestorationStep::Declipping {
detector: ClipDetector::new(ClipDetectorConfig::default()),
declipper: BasicDeclipper::new(DeclipConfig::default()),
});
}
if opts.dehum {
use oximedia_restore::hum::HumRemover;
chain.add_step(RestorationStep::HumRemoval(HumRemover::new_standard(
50.0,
sample_rate,
5,
10.0,
)));
chain.add_step(RestorationStep::HumRemoval(HumRemover::new_standard(
60.0,
sample_rate,
5,
10.0,
)));
}
if opts.denoise {
use oximedia_restore::noise::{NoiseGate, NoiseGateConfig};
chain.add_step(RestorationStep::NoiseGate(NoiseGate::new(
NoiseGateConfig::default(),
)));
}
}
}
let step_count = chain.len();
let restored = chain
.process(&samples, sample_rate)
.map_err(|e| anyhow::anyhow!("Restoration failed: {e}"))?;
let output_bytes = f32_samples_to_bytes(&restored);
std::fs::write(&opts.output, &output_bytes)
.with_context(|| format!("Failed to write output: {}", opts.output.display()))?;
if json_output {
let obj = serde_json::json!({
"input": opts.input.to_string_lossy(),
"output": opts.output.to_string_lossy(),
"mode": opts.mode,
"sample_rate": sample_rate,
"input_samples": samples.len(),
"output_samples": restored.len(),
"restoration_steps": step_count,
});
println!("{}", serde_json::to_string_pretty(&obj)?);
} else {
println!("{}", "Audio Restoration Complete".green().bold());
println!(" Input: {}", opts.input.display());
println!(" Output: {}", opts.output.display());
println!(" Mode: {}", opts.mode);
println!(" Sample rate: {} Hz", sample_rate);
println!(" Steps: {}", step_count);
println!(" Samples: {} -> {}", samples.len(), restored.len());
}
Ok(())
}
pub async fn run_restore_video(opts: RestoreVideoOptions, json_output: bool) -> Result<()> {
let mode_lc = opts.mode.to_lowercase();
if mode_lc == "stabilize" || mode_lc == "full" {
return run_restore_video_stabilize(opts, json_output).await;
}
run_restore_video_remux(opts, json_output).await
}
async fn run_restore_video_remux(opts: RestoreVideoOptions, json_output: bool) -> Result<()> {
use oximedia_transcode::frame_pipeline::{
FramePipelineConfig, FramePipelineExecutor, VideoFrameOp,
};
use oximedia_transcode::hdr_passthrough::HdrPassthroughMode;
let width = opts.width.unwrap_or(1920);
let height = opts.height.unwrap_or(1080);
let steps_applied: Vec<&str> = match opts.mode.to_lowercase().as_str() {
"deinterlace" => vec!["deinterlace"],
"upscale" => vec!["upscale"],
"color-correct" => vec!["color-correct"],
_ => vec!["deinterlace"],
};
let mut video_ops: Vec<VideoFrameOp> = Vec::new();
for step in &steps_applied {
match *step {
"deinterlace" => video_ops.push(VideoFrameOp::Deinterlace),
"upscale" => video_ops.push(VideoFrameOp::Scale { width, height }),
"color-correct" => video_ops.push(VideoFrameOp::ColorCorrect {
brightness: 1.05,
contrast: 1.1,
saturation: 1.0,
}),
_ => {}
}
}
let frame_cfg = FramePipelineConfig {
input: opts.input.clone(),
output: opts.output.clone(),
video_codec: None,
audio_codec: None,
video_ops,
audio_ops: Vec::new(),
hdr_mode: HdrPassthroughMode::Passthrough,
source_hdr: None,
hw_accel: false,
threads: 0,
};
let executor_result = tokio::task::spawn_blocking(move || {
let mut executor = FramePipelineExecutor::new(frame_cfg);
executor.execute()
})
.await
.map_err(|join_err| anyhow::anyhow!("executor task panicked: {join_err}"));
let (output_size, pipeline_used) = {
let output_path = opts.output.clone();
let input_path = opts.input.clone();
match executor_result.and_then(|r| r.map_err(|e| anyhow::anyhow!("{e}"))) {
Ok(result) => {
let written = std::fs::metadata(&output_path)
.map(|m| m.len())
.unwrap_or(result.output_bytes);
(written, true)
}
Err(e) => {
tracing::warn!(
"restore-video: frame pipeline failed ({}); \
falling back to byte copy — video ops not applied",
e
);
let data = std::fs::read(&input_path)
.with_context(|| format!("Failed to read input: {}", input_path.display()))?;
let sz = data.len() as u64;
std::fs::write(&output_path, &data).with_context(|| {
format!("Failed to write output: {}", output_path.display())
})?;
if !json_output {
println!(
" Note: frame pipeline failed ({}); byte copy used. \
Video ops (deinterlace/scale/color-correct) were not applied.",
e
);
}
(sz, false)
}
}
};
if json_output {
let obj = serde_json::json!({
"input": opts.input.to_string_lossy(),
"output": opts.output.to_string_lossy(),
"mode": opts.mode,
"target_resolution": format!("{width}x{height}"),
"output_size_bytes": output_size,
"steps_applied": steps_applied,
"pipeline_remux": pipeline_used,
"note": if pipeline_used {
"Container remuxed via FramePipelineExecutor. \
VideoFrameOp descriptors registered; pixel-level ops \
(deinterlace/scale/color-correct) require full decode→filter→encode \
which is not yet wired at the packet loop level."
} else {
"Byte copy used; frame pipeline not available for this format or encountered an error."
},
});
println!("{}", serde_json::to_string_pretty(&obj)?);
} else {
println!("{}", "Video Restoration Complete".green().bold());
println!(" Input: {}", opts.input.display());
println!(" Output: {}", opts.output.display());
println!(" Mode: {}", opts.mode);
println!(" Resolution: {}x{}", width, height);
println!(" Steps: {}", steps_applied.join(", "));
println!(" Output size: {} bytes", output_size);
println!(
" Pipeline: {}",
if pipeline_used {
"FramePipelineExecutor (container remux)"
} else {
"byte copy (pipeline unavailable)"
}
);
}
Ok(())
}
async fn run_restore_video_stabilize(opts: RestoreVideoOptions, json_output: bool) -> Result<()> {
let input = opts.input.clone();
let output = opts.output.clone();
let mode = opts.mode.clone();
let summary = tokio::task::spawn_blocking(move || stabilize_video_y4m(&input, &output))
.await
.map_err(|join_err| anyhow::anyhow!("stabilisation task panicked: {join_err}"))??;
if json_output {
let obj = serde_json::json!({
"input": opts.input.to_string_lossy(),
"output": opts.output.to_string_lossy(),
"mode": mode,
"operation": "stabilize",
"transcode": true,
"input_format": "y4m",
"output_format": "y4m",
"width": summary.width,
"height": summary.height,
"frame_count": summary.frame_count,
"input_size_bytes": summary.input_size,
"output_size_bytes": summary.output_size,
"bytes_changed": summary.bytes_changed,
"note": "Video stabilised via the oximedia-stabilize offline \
multi-pass pipeline (motion estimation, trajectory \
smoothing, frame warping). Output re-encoded as Y4M.",
});
println!("{}", serde_json::to_string_pretty(&obj)?);
} else {
println!("{}", "Video Restoration Complete".green().bold());
println!(" Input: {}", opts.input.display());
println!(" Output: {}", opts.output.display());
println!(" Mode: {}", mode);
println!(" Operation: stabilise (transcode)");
println!(" Resolution: {}x{}", summary.width, summary.height);
println!(" Frames: {}", summary.frame_count);
println!(
" Output size: {} bytes ({} bytes changed)",
summary.output_size, summary.bytes_changed
);
println!(
" Pipeline: oximedia-stabilize offline multi-pass (decode \u{2192} stabilise \u{2192} encode)"
);
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
struct StabilizeSummary {
width: u32,
height: u32,
frame_count: usize,
input_size: u64,
output_size: u64,
bytes_changed: u64,
}
fn stabilize_video_y4m(
input: &std::path::Path,
output: &std::path::Path,
) -> Result<StabilizeSummary> {
use oximedia_container::demux::y4m::Y4mDemuxer;
use oximedia_container::mux::y4m::Y4mMuxerBuilder;
let raw = std::fs::read(input)
.with_context(|| format!("Failed to read input: {}", input.display()))?;
let input_size = raw.len() as u64;
if !raw.starts_with(b"YUV4MPEG2") {
anyhow::bail!(
"restore-video --mode stabilize requires an uncompressed YUV4MPEG2 (.y4m) \
input, because stabilisation re-encodes every frame. '{}' is not a Y4M file. \
Convert it first (e.g. `oximedia transcode -i input.mp4 output.y4m`).",
input.display()
);
}
let mut demuxer = Y4mDemuxer::new(std::io::Cursor::new(raw.as_slice()))
.map_err(|e| anyhow::anyhow!("Failed to parse Y4M header: {e}"))?;
let header = demuxer.header().clone();
let width = header.width;
let height = header.height;
let chroma = header.chroma;
let frames_raw = demuxer
.read_all_frames()
.map_err(|e| anyhow::anyhow!("Failed to read Y4M frames: {e}"))?;
if frames_raw.is_empty() {
anyhow::bail!(
"Y4M input '{}' contains no frames; nothing to stabilise.",
input.display()
);
}
let layout = ChromaLayout::for_chroma(chroma, width, height).ok_or_else(|| {
anyhow::anyhow!(
"Y4M chroma format '{}' is not supported by the stabilise pipeline",
chroma
)
})?;
let stabilized = stabilize_planar_frames(&frames_raw, &layout)?;
let mut out_buf: Vec<u8> = Vec::with_capacity(raw.len());
{
let mut muxer = Y4mMuxerBuilder::new(width, height)
.fps(header.fps_num.max(1), header.fps_den.max(1))
.chroma(chroma)
.interlace(header.interlace)
.aspect_ratio(header.par_num, header.par_den)
.build(&mut out_buf)
.map_err(|e| anyhow::anyhow!("Failed to create Y4M muxer: {e}"))?;
for frame in &stabilized {
muxer
.write_frame(frame)
.map_err(|e| anyhow::anyhow!("Failed to write stabilised frame: {e}"))?;
}
muxer
.finish()
.map_err(|e| anyhow::anyhow!("Failed to finalise Y4M output: {e}"))?;
}
std::fs::write(output, &out_buf)
.with_context(|| format!("Failed to write output: {}", output.display()))?;
let bytes_changed = frames_raw
.iter()
.zip(stabilized.iter())
.map(|(a, b)| a.iter().zip(b.iter()).filter(|(x, y)| x != y).count() as u64)
.sum();
Ok(StabilizeSummary {
width,
height,
frame_count: stabilized.len(),
input_size,
output_size: out_buf.len() as u64,
bytes_changed,
})
}
#[derive(Debug, Clone, Copy)]
struct ChromaLayout {
luma_w: usize,
luma_h: usize,
chroma_w: usize,
chroma_h: usize,
has_alpha: bool,
}
impl ChromaLayout {
fn for_chroma(
chroma: oximedia_container::demux::y4m::Y4mChroma,
width: u32,
height: u32,
) -> Option<Self> {
use oximedia_container::demux::y4m::Y4mChroma;
let luma_w = width as usize;
let luma_h = height as usize;
let (chroma_w, chroma_h, has_alpha) = match chroma {
Y4mChroma::C420jpeg | Y4mChroma::C420mpeg2 | Y4mChroma::C420paldv => {
((luma_w + 1) / 2, (luma_h + 1) / 2, false)
}
Y4mChroma::C422 => ((luma_w + 1) / 2, luma_h, false),
Y4mChroma::C444 => (luma_w, luma_h, false),
Y4mChroma::C444alpha => (luma_w, luma_h, true),
Y4mChroma::Mono => (0, 0, false),
};
Some(Self {
luma_w,
luma_h,
chroma_w,
chroma_h,
has_alpha,
})
}
const fn frame_size(&self) -> usize {
self.luma_w * self.luma_h
+ 2 * self.chroma_w * self.chroma_h
+ if self.has_alpha {
self.luma_w * self.luma_h
} else {
0
}
}
}
fn stabilize_planar_frames(frames_raw: &[Vec<u8>], layout: &ChromaLayout) -> Result<Vec<Vec<u8>>> {
use oximedia_stabilize::motion::estimate::MotionEstimator;
use oximedia_stabilize::motion::tracker::MotionTracker;
use oximedia_stabilize::motion::trajectory::Trajectory;
use oximedia_stabilize::multipass::analyze::MultipassAnalyzer;
use oximedia_stabilize::smooth::filter::TrajectorySmoother;
use oximedia_stabilize::transform::calculate::{StabilizationTransform, TransformCalculator};
use oximedia_stabilize::warp::apply::FrameWarper;
use oximedia_stabilize::{Frame, QualityPreset, StabilizationMode, StabilizeConfig};
use scirs2_core::ndarray::Array2;
let expected = layout.frame_size();
for (i, f) in frames_raw.iter().enumerate() {
if f.len() != expected {
anyhow::bail!(
"Y4M frame {i} has {} bytes, expected {expected} for the declared geometry",
f.len()
);
}
}
let luma_frames: Vec<Frame> = frames_raw
.iter()
.enumerate()
.map(|(i, raw)| -> Result<Frame> {
let luma = &raw[..layout.luma_w * layout.luma_h];
let data = Array2::from_shape_vec((layout.luma_h, layout.luma_w), luma.to_vec())
.map_err(|e| anyhow::anyhow!("Failed to build luma array for frame {i}: {e}"))?;
Ok(Frame::new(
layout.luma_w,
layout.luma_h,
i as f64 / 30.0,
data,
))
})
.collect::<Result<_>>()?;
let config = StabilizeConfig::new()
.with_mode(StabilizationMode::Affine)
.with_quality(QualityPreset::Balanced)
.with_smoothing_strength(0.85);
config
.validate()
.map_err(|e| anyhow::anyhow!("Invalid stabilisation configuration: {e}"))?;
let analyzer = MultipassAnalyzer::new();
let analysis = analyzer
.analyze(&luma_frames)
.map_err(|e| anyhow::anyhow!("Multi-pass analysis failed: {e}"))?;
let smoothing_window = analysis
.recommended_window_size
.max(config.quality.smoothing_window())
.max(1);
let transforms: Vec<StabilizationTransform> = {
let mut tracker = MotionTracker::new(config.feature_count);
match tracker.track(&luma_frames) {
Ok(tracks) => {
let estimator = MotionEstimator::new(config.mode);
let models = estimator
.estimate(&tracks, luma_frames.len())
.map_err(|e| anyhow::anyhow!("Motion estimation failed: {e}"))?;
let trajectory = Trajectory::from_models(&models)
.map_err(|e| anyhow::anyhow!("Trajectory build failed: {e}"))?;
let mut smoother =
TrajectorySmoother::new(smoothing_window, config.smoothing_strength);
let smoothed = smoother
.smooth(&trajectory)
.map_err(|e| anyhow::anyhow!("Trajectory smoothing failed: {e}"))?;
let calculator = TransformCalculator::new();
calculator
.calculate(&trajectory, &smoothed)
.map_err(|e| anyhow::anyhow!("Transform calculation failed: {e}"))?
}
Err(oximedia_stabilize::StabilizeError::InsufficientFeatures { .. }) => {
(0..luma_frames.len())
.map(StabilizationTransform::identity)
.collect()
}
Err(e) => return Err(anyhow::anyhow!("Motion tracking failed: {e}")),
}
};
if transforms.len() != frames_raw.len() {
anyhow::bail!(
"stabiliser produced {} transforms for {} frames",
transforms.len(),
frames_raw.len()
);
}
let warper = FrameWarper::new();
let mut out_frames: Vec<Vec<u8>> = Vec::with_capacity(frames_raw.len());
for (raw, transform) in frames_raw.iter().zip(transforms.iter()) {
let mut out = vec![0u8; expected];
let mut offset = 0usize;
warp_plane(
&warper,
&raw[offset..offset + layout.luma_w * layout.luma_h],
layout.luma_w,
layout.luma_h,
transform,
1.0,
&mut out[offset..offset + layout.luma_w * layout.luma_h],
)?;
offset += layout.luma_w * layout.luma_h;
if layout.chroma_w > 0 && layout.chroma_h > 0 {
let plane_len = layout.chroma_w * layout.chroma_h;
let scale_x = layout.chroma_w as f64 / layout.luma_w.max(1) as f64;
let scale_y = layout.chroma_h as f64 / layout.luma_h.max(1) as f64;
let chroma_scale = (scale_x + scale_y) / 2.0;
for _ in 0..2 {
warp_plane(
&warper,
&raw[offset..offset + plane_len],
layout.chroma_w,
layout.chroma_h,
transform,
chroma_scale,
&mut out[offset..offset + plane_len],
)?;
offset += plane_len;
}
}
if layout.has_alpha {
let plane_len = layout.luma_w * layout.luma_h;
warp_plane(
&warper,
&raw[offset..offset + plane_len],
layout.luma_w,
layout.luma_h,
transform,
1.0,
&mut out[offset..offset + plane_len],
)?;
}
out_frames.push(out);
}
Ok(out_frames)
}
fn warp_plane(
warper: &oximedia_stabilize::warp::apply::FrameWarper,
src: &[u8],
plane_w: usize,
plane_h: usize,
transform: &oximedia_stabilize::transform::calculate::StabilizationTransform,
translation_scale: f64,
dst: &mut [u8],
) -> Result<()> {
use oximedia_stabilize::transform::calculate::StabilizationTransform;
use oximedia_stabilize::Frame;
use scirs2_core::ndarray::Array2;
if plane_w == 0 || plane_h == 0 {
return Ok(());
}
let data = Array2::from_shape_vec((plane_h, plane_w), src.to_vec())
.map_err(|e| anyhow::anyhow!("Failed to build plane array ({plane_w}x{plane_h}): {e}"))?;
let frame = Frame::new(plane_w, plane_h, 0.0, data);
let plane_transform = StabilizationTransform {
dx: transform.dx * translation_scale,
dy: transform.dy * translation_scale,
angle: transform.angle,
scale: transform.scale,
frame_index: transform.frame_index,
confidence: transform.confidence,
};
let warped = warper
.warp(
std::slice::from_ref(&frame),
std::slice::from_ref(&plane_transform),
)
.map_err(|e| anyhow::anyhow!("Frame warp failed: {e}"))?;
let warped_frame = warped
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("Frame warp returned no frame"))?;
let warped_bytes: Vec<u8> = warped_frame.data.iter().copied().collect();
if warped_bytes.len() != dst.len() {
anyhow::bail!(
"warped plane has {} bytes, expected {}",
warped_bytes.len(),
dst.len()
);
}
dst.copy_from_slice(&warped_bytes);
Ok(())
}
pub async fn run_restore_analyze(opts: RestoreAnalyzeOptions, json_output: bool) -> Result<()> {
let data = std::fs::read(&opts.input)
.with_context(|| format!("Failed to read input: {}", opts.input.display()))?;
let is_audio = match opts.analysis_type.to_lowercase().as_str() {
"audio" => true,
"video" => false,
_ => {
let ext = opts
.input
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
matches!(
ext.to_lowercase().as_str(),
"wav" | "flac" | "ogg" | "opus" | "pcm"
)
}
};
if is_audio {
let samples = bytes_to_f32_samples(&data);
let analysis = analyze_audio_degradation(&samples);
if json_output {
let obj = serde_json::json!({
"file": opts.input.to_string_lossy(),
"type": "audio",
"samples": samples.len(),
"degradation": analysis,
});
println!("{}", serde_json::to_string_pretty(&obj)?);
} else {
println!("{}", "Degradation Analysis".green().bold());
println!(" File: {}", opts.input.display());
println!(" Type: Audio ({} samples)", samples.len());
println!();
for (key, value) in &analysis {
println!(" {}: {}", key.cyan(), value);
}
}
} else {
let analysis = analyze_video_degradation(&data);
if json_output {
let obj = serde_json::json!({
"file": opts.input.to_string_lossy(),
"type": "video",
"size_bytes": data.len(),
"degradation": analysis,
});
println!("{}", serde_json::to_string_pretty(&obj)?);
} else {
println!("{}", "Degradation Analysis".green().bold());
println!(" File: {}", opts.input.display());
println!(" Type: Video ({} bytes)", data.len());
println!();
for (key, value) in &analysis {
println!(" {}: {}", key.cyan(), value);
}
}
}
Ok(())
}
pub async fn run_restore_batch(opts: RestoreBatchOptions, json_output: bool) -> Result<()> {
use oximedia_restore::presets::VinylRestoration;
use oximedia_restore::RestoreChain;
std::fs::create_dir_all(&opts.output_dir)
.with_context(|| format!("Failed to create output dir: {}", opts.output_dir.display()))?;
let entries: Vec<_> = std::fs::read_dir(&opts.input_dir)
.with_context(|| format!("Failed to read directory: {}", opts.input_dir.display()))?
.filter_map(|e| e.ok())
.filter(|e| {
if let Some(ref ext_filter) = opts.extension {
e.path()
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase() == ext_filter.to_lowercase())
.unwrap_or(false)
} else {
true
}
})
.collect();
let mut results = Vec::new();
let sample_rate = 44100_u32;
for entry in &entries {
let input_path = entry.path();
let file_name = input_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let output_path = opts.output_dir.join(file_name);
let data = match std::fs::read(&input_path) {
Ok(d) => d,
Err(e) => {
results.push(serde_json::json!({
"file": file_name,
"status": "error",
"message": format!("{e}"),
}));
continue;
}
};
let samples = bytes_to_f32_samples(&data);
let mut chain = RestoreChain::new();
chain.add_preset(VinylRestoration::new(sample_rate));
match chain.process(&samples, sample_rate) {
Ok(restored) => {
let output_bytes = f32_samples_to_bytes(&restored);
if let Err(e) = std::fs::write(&output_path, &output_bytes) {
results.push(serde_json::json!({
"file": file_name,
"status": "error",
"message": format!("Write failed: {e}"),
}));
} else {
results.push(serde_json::json!({
"file": file_name,
"status": "ok",
"input_samples": samples.len(),
"output_samples": restored.len(),
}));
}
}
Err(e) => {
results.push(serde_json::json!({
"file": file_name,
"status": "error",
"message": format!("{e}"),
}));
}
}
}
if json_output {
let obj = serde_json::json!({
"input_dir": opts.input_dir.to_string_lossy(),
"output_dir": opts.output_dir.to_string_lossy(),
"mode": opts.mode,
"total_files": entries.len(),
"results": results,
});
println!("{}", serde_json::to_string_pretty(&obj)?);
} else {
println!("{}", "Batch Restoration Complete".green().bold());
println!(" Input: {}", opts.input_dir.display());
println!(" Output: {}", opts.output_dir.display());
println!(" Mode: {}", opts.mode);
println!(" Files: {}", entries.len());
println!();
for r in &results {
let file = r["file"].as_str().unwrap_or("?");
let status = r["status"].as_str().unwrap_or("?");
if status == "ok" {
println!(" {} {}", "OK".green(), file);
} else {
let msg = r["message"].as_str().unwrap_or("unknown error");
println!(" {} {} - {}", "FAIL".red(), file, msg);
}
}
}
Ok(())
}
pub async fn run_restore_compare(opts: RestoreCompareOptions, json_output: bool) -> Result<()> {
let original_data = std::fs::read(&opts.original)
.with_context(|| format!("Failed to read original: {}", opts.original.display()))?;
let restored_data = std::fs::read(&opts.restored)
.with_context(|| format!("Failed to read restored: {}", opts.restored.display()))?;
let original_samples = bytes_to_f32_samples(&original_data);
let restored_samples = bytes_to_f32_samples(&restored_data);
let comparison = compare_audio(&original_samples, &restored_samples);
if json_output {
let obj = serde_json::json!({
"original": opts.original.to_string_lossy(),
"restored": opts.restored.to_string_lossy(),
"original_samples": original_samples.len(),
"restored_samples": restored_samples.len(),
"metrics": comparison,
});
println!("{}", serde_json::to_string_pretty(&obj)?);
} else {
println!("{}", "Restoration Comparison".green().bold());
println!(" Original: {}", opts.original.display());
println!(" Restored: {}", opts.restored.display());
println!();
for (key, value) in &comparison {
println!(" {}: {}", key.cyan(), value);
}
}
Ok(())
}
fn bytes_to_f32_samples(data: &[u8]) -> Vec<f32> {
data.chunks_exact(4)
.map(|chunk| {
let arr: [u8; 4] = [chunk[0], chunk[1], chunk[2], chunk[3]];
f32::from_le_bytes(arr)
})
.collect()
}
fn f32_samples_to_bytes(samples: &[f32]) -> Vec<u8> {
samples.iter().flat_map(|s| s.to_le_bytes()).collect()
}
fn analyze_audio_degradation(samples: &[f32]) -> Vec<(String, String)> {
let mut results = Vec::new();
if samples.is_empty() {
results.push(("Status".to_string(), "No samples to analyze".to_string()));
return results;
}
let peak = samples.iter().fold(0.0_f32, |max, &s| max.max(s.abs()));
results.push(("Peak Level".to_string(), format!("{peak:.4}")));
let clip_count = samples.iter().filter(|&&s| s.abs() >= 0.999).count();
let clip_pct = (clip_count as f64 / samples.len() as f64) * 100.0;
results.push((
"Clipping".to_string(),
format!("{clip_count} samples ({clip_pct:.2}%)"),
));
let dc_offset: f64 = samples.iter().map(|&s| s as f64).sum::<f64>() / samples.len() as f64;
results.push(("DC Offset".to_string(), format!("{dc_offset:.6}")));
let rms: f64 = (samples
.iter()
.map(|&s| (s as f64) * (s as f64))
.sum::<f64>()
/ samples.len() as f64)
.sqrt();
results.push(("RMS Level".to_string(), format!("{rms:.4}")));
if rms > 0.0 {
let crest = peak as f64 / rms;
results.push(("Crest Factor".to_string(), format!("{crest:.2}")));
}
results
}
fn analyze_video_degradation(data: &[u8]) -> Vec<(String, String)> {
let mut results = Vec::new();
results.push(("File Size".to_string(), format!("{} bytes", data.len())));
let mut histogram = [0u64; 256];
for &b in data {
histogram[b as usize] += 1;
}
let total = data.len() as f64;
let entropy: f64 = histogram
.iter()
.filter(|&&c| c > 0)
.map(|&c| {
let p = c as f64 / total;
-p * p.log2()
})
.sum();
results.push(("Entropy".to_string(), format!("{entropy:.4} bits/byte")));
let unique = histogram.iter().filter(|&&c| c > 0).count();
results.push(("Unique Byte Values".to_string(), format!("{unique}/256")));
results
}
fn compare_audio(original: &[f32], restored: &[f32]) -> Vec<(String, String)> {
let mut results = Vec::new();
results.push((
"Original Samples".to_string(),
format!("{}", original.len()),
));
results.push((
"Restored Samples".to_string(),
format!("{}", restored.len()),
));
let len = original.len().min(restored.len());
if len == 0 {
results.push(("Status".to_string(), "No overlapping samples".to_string()));
return results;
}
let mse: f64 = original[..len]
.iter()
.zip(&restored[..len])
.map(|(&a, &b)| {
let diff = (a as f64) - (b as f64);
diff * diff
})
.sum::<f64>()
/ len as f64;
results.push(("MSE".to_string(), format!("{mse:.8}")));
let original_power: f64 = original[..len]
.iter()
.map(|&s| (s as f64) * (s as f64))
.sum::<f64>()
/ len as f64;
if mse > 0.0 {
let snr_db = 10.0 * (original_power / mse).log10();
results.push(("SNR (dB)".to_string(), format!("{snr_db:.2}")));
}
let max_diff = original[..len]
.iter()
.zip(&restored[..len])
.map(|(&a, &b)| (a - b).abs())
.fold(0.0_f32, f32::max);
results.push(("Max Difference".to_string(), format!("{max_diff:.6}")));
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bytes_to_f32_roundtrip() {
let samples = vec![0.5_f32, -0.25, 1.0, -1.0, 0.0];
let bytes = f32_samples_to_bytes(&samples);
let recovered = bytes_to_f32_samples(&bytes);
assert_eq!(samples, recovered);
}
#[test]
fn test_analyze_audio_empty() {
let result = analyze_audio_degradation(&[]);
assert_eq!(result.len(), 1);
assert!(result[0].1.contains("No samples"));
}
#[test]
fn test_analyze_audio_clipping() {
let samples = vec![1.0_f32; 100];
let result = analyze_audio_degradation(&samples);
let clip_entry = result.iter().find(|(k, _)| k == "Clipping");
assert!(clip_entry.is_some());
let clip_str = &clip_entry.expect("clipping entry should exist").1;
assert!(clip_str.contains("100 samples"));
}
#[test]
fn test_compare_audio_identical() {
let samples = vec![0.5_f32; 100];
let result = compare_audio(&samples, &samples);
let mse_entry = result.iter().find(|(k, _)| k == "MSE");
assert!(mse_entry.is_some());
let mse_str = &mse_entry.expect("MSE entry should exist").1;
assert!(mse_str.starts_with("0.0"));
}
#[test]
fn test_analyze_video_degradation() {
let data = vec![0u8, 1, 2, 3, 4, 5, 100, 200, 255];
let result = analyze_video_degradation(&data);
assert!(result.len() >= 3);
let entropy_entry = result.iter().find(|(k, _)| k == "Entropy");
assert!(entropy_entry.is_some());
}
#[test]
fn test_decode_audio_file_wav_silent_sample() {
let mut wav: Vec<u8> = Vec::new();
wav.extend_from_slice(b"RIFF"); wav.extend_from_slice(&36u32.to_le_bytes()); wav.extend_from_slice(b"WAVE"); wav.extend_from_slice(b"fmt "); wav.extend_from_slice(&16u32.to_le_bytes()); wav.extend_from_slice(&1u16.to_le_bytes()); wav.extend_from_slice(&1u16.to_le_bytes()); wav.extend_from_slice(&44100u32.to_le_bytes()); wav.extend_from_slice(&88200u32.to_le_bytes()); wav.extend_from_slice(&2u16.to_le_bytes()); wav.extend_from_slice(&16u16.to_le_bytes()); wav.extend_from_slice(b"data"); wav.extend_from_slice(&2u32.to_le_bytes()); wav.extend_from_slice(&0i16.to_le_bytes()); let (samples, rate) = decode_audio_file(&wav, "wav").expect("WAV decode should succeed");
assert_eq!(rate, 44100);
assert_eq!(samples.len(), 1);
assert!((samples[0]).abs() < 1e-4, "silent sample should be ~0.0");
}
#[test]
fn test_decode_audio_file_wav_stereo_downmix() {
let mut wav: Vec<u8> = Vec::new();
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&40u32.to_le_bytes()); wav.extend_from_slice(b"WAVE");
wav.extend_from_slice(b"fmt ");
wav.extend_from_slice(&16u32.to_le_bytes()); wav.extend_from_slice(&1u16.to_le_bytes()); wav.extend_from_slice(&2u16.to_le_bytes()); wav.extend_from_slice(&44100u32.to_le_bytes()); wav.extend_from_slice(&176400u32.to_le_bytes()); wav.extend_from_slice(&4u16.to_le_bytes()); wav.extend_from_slice(&16u16.to_le_bytes()); wav.extend_from_slice(b"data");
wav.extend_from_slice(&8u32.to_le_bytes()); wav.extend_from_slice(&i16::MAX.to_le_bytes());
wav.extend_from_slice(&i16::MIN.to_le_bytes());
wav.extend_from_slice(&0i16.to_le_bytes());
wav.extend_from_slice(&0i16.to_le_bytes());
let riff_size = (wav.len() - 8) as u32;
wav[4..8].copy_from_slice(&riff_size.to_le_bytes());
let (samples, rate) =
decode_audio_file(&wav, "wav").expect("stereo WAV decode should succeed");
assert_eq!(rate, 44100);
assert_eq!(samples.len(), 2, "2 stereo frames → 2 mono samples");
assert!(samples[0].abs() < 0.01, "near-zero after L+R average");
}
#[test]
fn test_decode_audio_file_unsupported_ext() {
let result = decode_audio_file(b"junk", "xyz");
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("Unsupported") || msg.contains("supported"),
"error should mention supported formats, got: {msg}"
);
}
#[test]
fn test_decode_audio_file_flac_returns_error() {
let result = decode_audio_file(b"fLaC\x00\x00\x00\x00", "flac");
assert!(result.is_err(), "FLAC should return Err (stub decoder)");
}
#[tokio::test]
async fn test_restore_audio_unsupported_format_error() {
let dir = std::env::temp_dir();
let input = dir.join("oximedia_test_restore_0177.xyz");
let output = dir.join("oximedia_test_restore_0177_out.wav");
std::fs::write(&input, b"junk data").expect("write junk");
let opts = RestoreAudioOptions {
input: input.clone(),
output: output.clone(),
mode: "broadcast".to_string(),
sample_rate: None,
declip: false,
decrackle: false,
dehum: false,
denoise: false,
raw: false,
};
let result = run_restore_audio(opts, false).await;
assert!(result.is_err(), "unsupported format should return Err");
let err_str = format!("{}", result.unwrap_err());
assert!(
err_str.contains("Unsupported") || err_str.contains("supported"),
"error message should mention supported formats: {err_str}"
);
let _ = std::fs::remove_file(&input);
let _ = std::fs::remove_file(&output);
}
#[tokio::test]
async fn test_restore_audio_raw_flag() {
let dir = std::env::temp_dir();
let samples: Vec<f32> = vec![0.0, 0.1, -0.1, 0.5];
let raw_bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
let input = dir.join("oximedia_test_restore_0177_raw.xyz");
let output = dir.join("oximedia_test_restore_0177_raw_out.xyz");
std::fs::write(&input, &raw_bytes).expect("write raw pcm");
let opts = RestoreAudioOptions {
input: input.clone(),
output: output.clone(),
mode: "broadcast".to_string(),
sample_rate: Some(44100),
declip: false,
decrackle: false,
dehum: false,
denoise: false,
raw: true,
};
let result = run_restore_audio(opts, false).await;
assert!(
result.is_ok(),
"raw flag should succeed: {:?}",
result.err()
);
let _ = std::fs::remove_file(&input);
let _ = std::fs::remove_file(&output);
}
#[tokio::test]
async fn test_restore_audio_wav_decode() {
let dir = std::env::temp_dir();
let input = dir.join("oximedia_test_restore_0177_wav.wav");
let output = dir.join("oximedia_test_restore_0177_wav_out.wav");
let mut wav: Vec<u8> = Vec::new();
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&36u32.to_le_bytes());
wav.extend_from_slice(b"WAVE");
wav.extend_from_slice(b"fmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&44100u32.to_le_bytes());
wav.extend_from_slice(&88200u32.to_le_bytes());
wav.extend_from_slice(&2u16.to_le_bytes());
wav.extend_from_slice(&16u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&2u32.to_le_bytes());
wav.extend_from_slice(&0i16.to_le_bytes());
std::fs::write(&input, &wav).expect("write wav");
let opts = RestoreAudioOptions {
input: input.clone(),
output: output.clone(),
mode: "broadcast".to_string(),
sample_rate: None,
declip: false,
decrackle: false,
dehum: false,
denoise: false,
raw: false,
};
let result = run_restore_audio(opts, false).await;
let _ = result;
let _ = std::fs::remove_file(&input);
let _ = std::fs::remove_file(&output);
}
#[test]
fn test_frame_pipeline_config_with_new_ops() {
use oximedia_transcode::frame_pipeline::{FramePipelineConfig, VideoFrameOp};
use oximedia_transcode::hdr_passthrough::HdrPassthroughMode;
let dir = std::env::temp_dir();
let cfg = FramePipelineConfig {
input: dir.join("dummy_in.mkv"),
output: dir.join("dummy_out.mkv"),
video_codec: None,
audio_codec: None,
video_ops: vec![
VideoFrameOp::Deinterlace,
VideoFrameOp::ColorCorrect {
brightness: 1.05,
contrast: 1.1,
saturation: 1.0,
},
VideoFrameOp::Scale {
width: 1920,
height: 1080,
},
],
audio_ops: Vec::new(),
hdr_mode: HdrPassthroughMode::Passthrough,
source_hdr: None,
hw_accel: false,
threads: 0,
};
assert_eq!(cfg.video_ops.len(), 3);
let debug_str = format!("{:?}", cfg.video_ops[0]);
assert!(debug_str.contains("Deinterlace"), "variant name in Debug");
let debug_str2 = format!("{:?}", cfg.video_ops[1]);
assert!(debug_str2.contains("ColorCorrect"), "variant name in Debug");
}
#[tokio::test]
async fn test_restore_video_does_not_panic() {
let dir = std::env::temp_dir();
let input = dir.join("oximedia_test_restore_0177_vid.bin");
let output = dir.join("oximedia_test_restore_0177_vid_out.bin");
std::fs::write(&input, b"not-a-real-video-file").expect("write dummy video");
let opts = RestoreVideoOptions {
input: input.clone(),
output: output.clone(),
mode: "deinterlace".to_string(),
width: None,
height: None,
};
let _result = run_restore_video(opts, false).await;
let _ = std::fs::remove_file(&input);
let _ = std::fs::remove_file(&output);
}
fn build_jittered_y4m(
width: u32,
height: u32,
frame_count: usize,
jitter: impl Fn(usize) -> (i32, i32),
) -> Vec<u8> {
let w = width as usize;
let h = height as usize;
let cw = (w + 1) / 2;
let ch = (h + 1) / 2;
let scene_luma = |x: i32, y: i32| -> u8 {
let grid = ((x % 16) < 3) || ((y % 16) < 3);
let block = ((x / 12 + y / 12) % 2) == 0;
match (grid, block) {
(true, _) => 235,
(false, true) => 180,
(false, false) => 32,
}
};
let mut data = Vec::new();
let header = format!("YUV4MPEG2 W{width} H{height} F30:1 Ip C420jpeg\n");
data.extend_from_slice(header.as_bytes());
for f in 0..frame_count {
let (jx, jy) = jitter(f);
data.extend_from_slice(b"FRAME\n");
for y in 0..h {
for x in 0..w {
let sx = x as i32 + jx;
let sy = y as i32 + jy;
data.push(scene_luma(sx, sy));
}
}
for _plane in 0..2 {
for y in 0..ch {
for x in 0..cw {
let sx = x as i32 + jx;
let sy = y as i32 + jy;
let v = 128i32 + ((sx + sy) % 17) - 8;
data.push(v.clamp(16, 240) as u8);
}
}
}
}
data
}
#[tokio::test]
async fn test_restore_video_stabilize_y4m_end_to_end() {
let dir = std::env::temp_dir();
let input = dir.join("oximedia_test_restore_s7_stab_in.y4m");
let output = dir.join("oximedia_test_restore_s7_stab_out.y4m");
let jitter = |f: usize| {
let t = f as f64;
let dx = (t * 0.9).sin() * 4.0;
let dy = (t * 0.7).cos() * 3.0;
(dx.round() as i32, dy.round() as i32)
};
let y4m = build_jittered_y4m(64, 48, 16, jitter);
std::fs::write(&input, &y4m).expect("write jittered y4m");
let opts = RestoreVideoOptions {
input: input.clone(),
output: output.clone(),
mode: "stabilize".to_string(),
width: None,
height: None,
};
let result = run_restore_video(opts, false).await;
assert!(
result.is_ok(),
"stabilise should succeed on a Y4M clip: {:?}",
result.err()
);
let out_bytes = std::fs::read(&output).expect("read stabilised output");
assert!(
out_bytes.starts_with(b"YUV4MPEG2"),
"output must be a Y4M stream"
);
let frame_tags = out_bytes.windows(6).filter(|w| *w == b"FRAME\n").count();
assert_eq!(frame_tags, 16, "all 16 frames must be re-encoded");
assert_ne!(
out_bytes, y4m,
"stabilised output must differ from the jittered input"
);
let _ = std::fs::remove_file(&input);
let _ = std::fs::remove_file(&output);
}
#[test]
fn test_stabilize_video_y4m_reduces_jitter() {
let dir = std::env::temp_dir();
let input = dir.join("oximedia_test_restore_s7_direct_in.y4m");
let output = dir.join("oximedia_test_restore_s7_direct_out.y4m");
let jitter = |f: usize| {
let phase = (f % 6) as i32;
(phase - 3, (phase * 2) - 5)
};
let y4m = build_jittered_y4m(80, 64, 14, jitter);
std::fs::write(&input, &y4m).expect("write y4m");
let summary = stabilize_video_y4m(&input, &output).expect("stabilise should succeed");
assert_eq!(summary.width, 80);
assert_eq!(summary.height, 64);
assert_eq!(summary.frame_count, 14);
assert!(summary.output_size > 0, "output must be non-empty");
assert!(
summary.bytes_changed > 0,
"warping a jittered clip must change frame bytes"
);
use oximedia_container::demux::y4m::Y4mDemuxer;
let out_bytes = std::fs::read(&output).expect("read output");
let mut demuxer =
Y4mDemuxer::new(std::io::Cursor::new(out_bytes.as_slice())).expect("parse output y4m");
assert_eq!(demuxer.width(), 80);
assert_eq!(demuxer.height(), 64);
let frames = demuxer.read_all_frames().expect("read output frames");
assert_eq!(frames.len(), 14);
let _ = std::fs::remove_file(&input);
let _ = std::fs::remove_file(&output);
}
#[tokio::test]
async fn test_restore_video_stabilize_rejects_non_y4m() {
let dir = std::env::temp_dir();
let input = dir.join("oximedia_test_restore_s7_bad_in.mp4");
let output = dir.join("oximedia_test_restore_s7_bad_out.y4m");
std::fs::write(&input, b"\x00\x00\x00\x18ftypmp42not-a-y4m").expect("write fake mp4");
let opts = RestoreVideoOptions {
input: input.clone(),
output: output.clone(),
mode: "stabilize".to_string(),
width: None,
height: None,
};
let result = run_restore_video(opts, false).await;
assert!(result.is_err(), "non-Y4M stabilise input must error");
let msg = format!("{}", result.expect_err("must be an error"));
assert!(
msg.contains("YUV4MPEG2") || msg.contains("Y4M"),
"error must point the user at the Y4M requirement, got: {msg}"
);
let _ = std::fs::remove_file(&input);
let _ = std::fs::remove_file(&output);
}
#[test]
fn test_chroma_layout_plane_sizes() {
use oximedia_container::demux::y4m::Y4mChroma;
let l420 = ChromaLayout::for_chroma(Y4mChroma::C420jpeg, 64, 48).expect("420 layout");
assert_eq!(l420.frame_size(), 64 * 48 + 2 * 32 * 24);
let l422 = ChromaLayout::for_chroma(Y4mChroma::C422, 64, 48).expect("422 layout");
assert_eq!(l422.frame_size(), 64 * 48 + 2 * 32 * 48);
let l444 = ChromaLayout::for_chroma(Y4mChroma::C444, 64, 48).expect("444 layout");
assert_eq!(l444.frame_size(), 64 * 48 * 3);
let lmono = ChromaLayout::for_chroma(Y4mChroma::Mono, 64, 48).expect("mono layout");
assert_eq!(lmono.frame_size(), 64 * 48);
}
}