Compare commits

...

2 Commits

Author SHA1 Message Date
e3ff11d8a8 Spectrometer didn't seem to calculate the graph correctly so i fixed
this with good logic which does work
2026-01-18 19:19:46 +01:00
fa020e0b36 Add spectrometer
Edit the logic so we dont just create pictures which we mash together
but instead use stream for better implementation
2026-01-18 19:18:30 +01:00
7 changed files with 350 additions and 175 deletions

49
Cargo.lock generated
View File

@ -638,6 +638,15 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-derive" name = "num-derive"
version = "0.4.2" version = "0.4.2"
@ -699,6 +708,7 @@ dependencies = [
"hound", "hound",
"image", "image",
"rayon", "rayon",
"rustfft",
] ]
[[package]] [[package]]
@ -735,6 +745,15 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.105" version = "1.0.105"
@ -907,6 +926,20 @@ version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
[[package]]
name = "rustfft"
version = "6.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@ -946,6 +979,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@ -997,6 +1036,16 @@ dependencies = [
"zune-jpeg 0.4.21", "zune-jpeg 0.4.21",
] ]
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.22" version = "1.0.22"

View File

@ -28,6 +28,9 @@ clap = { version = "4.4", features = ["derive"] }
# Error handling # Error handling
anyhow = "1.0" anyhow = "1.0"
# FFT processing for spectrometer
rustfft = "6.1"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
lto = true lto = true

View File

@ -3,7 +3,6 @@
//! Handles reading and decoding WAV files into normalized sample data. //! Handles reading and decoding WAV files into normalized sample data.
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use hound::WavReader;
use std::path::Path; use std::path::Path;
/// Normalized audio sample data. /// Normalized audio sample data.
@ -20,61 +19,44 @@ pub struct AudioData {
} }
impl AudioData { impl AudioData {
/// Load and decode a WAV file. /// Load and decode audio from any supported format using ffmpeg.
///
/// # Arguments
///
/// * `file_path` - Path to the WAV file
///
/// # Returns
///
/// `Result<AudioData>` containing the decoded audio samples
///
/// # Errors
///
/// Returns an error if:
/// - The file cannot be opened
/// - Bit depth is not 16-bit
/// - Number of channels is not 1 or 2
pub fn from_wav(file_path: &Path) -> Result<Self> { pub fn from_wav(file_path: &Path) -> Result<Self> {
let mut reader = WavReader::open(file_path) let output = std::process::Command::new("ffmpeg")
.with_context(|| format!("Failed to open WAV file: {}", file_path.display()))?; .arg("-i")
.arg(file_path)
.arg("-f")
.arg("s16le")
.arg("-acodec")
.arg("pcm_s16le")
.arg("-ar")
.arg("48000")
.arg("-ac")
.arg("2")
.arg("-")
.output()
.with_context(|| "Failed to decode audio with ffmpeg")?;
let spec = reader.spec(); if !output.status.success() {
return Err(anyhow!("Audio decoding failed: {}", String::from_utf8_lossy(&output.stderr)));
if spec.bits_per_sample != 16 {
return Err(anyhow!(
"Unsupported bit depth: {}. Only 16-bit WAV is supported.",
spec.bits_per_sample
));
} }
if spec.channels != 1 && spec.channels != 2 { let pcm_data = output.stdout;
return Err(anyhow!( let sample_rate = 48000;
"Unsupported number of channels: {}. Only mono and stereo are supported.", let num_channels = 2;
spec.channels let total_samples = pcm_data.len() / (2 * num_channels);
));
}
let sample_rate = spec.sample_rate;
let samples: Vec<i16> = reader.samples().map(|s| s.unwrap_or(0)).collect();
let total_samples = samples.len() / spec.channels as usize;
let duration = total_samples as f64 / sample_rate as f64; let duration = total_samples as f64 / sample_rate as f64;
let mut left_channel = Vec::with_capacity(total_samples); let mut left_channel = Vec::with_capacity(total_samples);
let mut right_channel = Vec::with_capacity(total_samples); let mut right_channel = Vec::with_capacity(total_samples);
for i in 0..total_samples { for i in 0..total_samples {
let offset = i * spec.channels as usize; let offset = i * 2 * num_channels;
let left_sample = samples[offset] as f32 / 32768.0;
left_channel.push(left_sample);
if spec.channels >= 2 { let left_val = i16::from_le_bytes([pcm_data[offset], pcm_data[offset + 1]]);
let right_sample = samples[offset + 1] as f32 / 32768.0; let right_val = i16::from_le_bytes([pcm_data[offset + 2], pcm_data[offset + 3]]);
right_channel.push(right_sample);
} else { left_channel.push(left_val as f32 / 32768.0);
right_channel.push(left_sample); right_channel.push(right_val as f32 / 32768.0);
}
} }
Ok(AudioData { Ok(AudioData {

View File

@ -21,5 +21,5 @@ pub mod render;
pub mod video; pub mod video;
pub use audio::AudioData; pub use audio::AudioData;
pub use render::{RenderMode, RenderOptions}; pub use render::{stream_frames, RenderMode, RenderOptions};
pub use video::encode_video; pub use video::VideoEncoder;

View File

@ -11,14 +11,15 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc; use std::sync::Arc;
use oscilloscope_video_gen::audio::AudioData; use oscilloscope_video_gen::audio::AudioData;
use oscilloscope_video_gen::render::{parse_rgb_hex, render_frames, RenderMode, RenderOptions}; use oscilloscope_video_gen::render::{parse_rgb_hex, stream_frames, RenderMode, RenderOptions};
use oscilloscope_video_gen::video::{encode_video, cleanup_tmp_dir, VideoQuality}; use oscilloscope_video_gen::video::{VideoEncoder, VideoQuality};
#[derive(Debug, Clone, Copy, ValueEnum)] #[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputMode { enum OutputMode {
Combined, Combined,
Separate, Separate,
All, All,
Spectrometer,
} }
impl From<OutputMode> for RenderMode { impl From<OutputMode> for RenderMode {
@ -27,6 +28,7 @@ impl From<OutputMode> for RenderMode {
OutputMode::Combined => RenderMode::Combined, OutputMode::Combined => RenderMode::Combined,
OutputMode::Separate => RenderMode::Separate, OutputMode::Separate => RenderMode::Separate,
OutputMode::All => RenderMode::All, OutputMode::All => RenderMode::All,
OutputMode::Spectrometer => RenderMode::Spectrometer,
} }
} }
} }
@ -73,7 +75,7 @@ struct Args {
#[arg(long, default_value = "30")] #[arg(long, default_value = "30")]
fps: u32, fps: u32,
/// Display mode: combined, separate, all /// Display mode: combined, separate, all, spectrometer
#[arg(long, value_enum, default_value = "all")] #[arg(long, value_enum, default_value = "all")]
mode: OutputMode, mode: OutputMode,
@ -196,30 +198,17 @@ fn main() -> Result<()> {
); );
} }
// Create temp directory
let tmp_dir = std::env::temp_dir().join("oscilloscope-render");
if tmp_dir.exists() {
cleanup_tmp_dir(&tmp_dir);
}
std::fs::create_dir_all(&tmp_dir)?;
// Progress callback // Progress callback
let progress = Arc::new(AtomicUsize::new(0)); let progress = Arc::new(AtomicUsize::new(0));
let progress_callback = move |percent: f64, current: usize, total: usize| { let progress_callback = move |percent: f64, current: usize, total: usize| {
let prev = progress.fetch_add(0, Ordering::SeqCst); let prev = progress.fetch_add(0, Ordering::SeqCst);
if current - prev >= 30 || current == total || current == 1 { if current - prev >= 30 || current == total || current == 1 {
progress.store(current, Ordering::SeqCst); progress.store(current, Ordering::SeqCst);
print!("\rRendering: {:.0}% ({}/{})", percent, current, total); print!("\rRendering and Encoding: {:.0}% ({}/{})", percent, current, total);
let _ = std::io::stdout().flush(); let _ = std::io::stdout().flush();
} }
}; };
// Render frames
println!("Rendering frames...");
let frame_files = render_frames(&audio_data, &options, &tmp_dir, &progress_callback)?;
println!();
println!("Rendered {} frames", frame_files.len());
// Check if output exists and handle overwrite // Check if output exists and handle overwrite
if output.exists() && !args.overwrite { if output.exists() && !args.overwrite {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
@ -228,20 +217,21 @@ fn main() -> Result<()> {
)); ));
} }
// Encode video let mut encoder = VideoEncoder::new(
encode_video(
&frame_files,
&args.input, &args.input,
&output, &output,
args.width,
args.height,
args.fps, args.fps,
args.quality.into(), args.quality.into(),
args.overwrite, args.overwrite,
) )?;
.context("Failed to encode video")?;
// Cleanup println!("Rendering and encoding...");
println!("Cleaning up temporary files..."); stream_frames(&audio_data, &options, &mut encoder, &progress_callback)?;
cleanup_tmp_dir(&tmp_dir); println!();
encoder.finish().context("Failed to finish video encoding")?;
let file_size = std::fs::metadata(&output) let file_size = std::fs::metadata(&output)
.map(|m| m.len()) .map(|m| m.len())

View File

@ -3,9 +3,10 @@
//! Contains all the logic for drawing oscilloscope visualizations. //! Contains all the logic for drawing oscilloscope visualizations.
use crate::audio::AudioData; use crate::audio::AudioData;
use anyhow::{anyhow, Context, Result}; use crate::video::VideoEncoder;
use anyhow::{anyhow, Result};
use image::ImageBuffer; use image::ImageBuffer;
use std::path::{Path, PathBuf}; use rustfft::{num_complex::Complex, FftPlanner};
/// Render mode for the oscilloscope visualization. /// Render mode for the oscilloscope visualization.
#[derive(Debug, Clone, Copy, clap::ValueEnum)] #[derive(Debug, Clone, Copy, clap::ValueEnum)]
@ -16,6 +17,8 @@ pub enum RenderMode {
Separate, Separate,
/// Left and Right on top row, XY on bottom /// Left and Right on top row, XY on bottom
All, All,
/// Frequency spectrum display (spectrometer)
Spectrometer,
} }
/// Rendering options for the visualization. /// Rendering options for the visualization.
@ -96,7 +99,7 @@ fn draw_graticule(
pub fn parse_rgb_hex(hex: &str) -> Result<image::Rgb<u8>> { pub fn parse_rgb_hex(hex: &str) -> Result<image::Rgb<u8>> {
let hex = hex.trim_start_matches('#'); let hex = hex.trim_start_matches('#');
if hex.len() != 6 { if hex.len() != 6 {
return Err(anyhow::anyhow!("Invalid RGB hex: {}", hex)); return Err(anyhow!("Invalid RGB hex: {}", hex));
} }
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| anyhow!("Invalid red component: {}", &hex[0..2]))?; let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| anyhow!("Invalid red component: {}", &hex[0..2]))?;
let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| anyhow!("Invalid green component: {}", &hex[2..4]))?; let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| anyhow!("Invalid green component: {}", &hex[2..4]))?;
@ -104,6 +107,109 @@ pub fn parse_rgb_hex(hex: &str) -> Result<image::Rgb<u8>> {
Ok(image::Rgb([r, g, b])) Ok(image::Rgb([r, g, b]))
} }
/// Compute frequency spectrum from audio samples using FFT.
fn compute_spectrum(audio_data: &AudioData, start_sample: usize, window_size: usize) -> Vec<f32> {
// Use a larger FFT size for better frequency resolution, especially in the bass
let fft_size = 2048;
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(fft_size);
// Collect audio samples for this window
let mut buffer: Vec<Complex<f32>> = (0..fft_size)
.map(|i| {
let sample_idx = start_sample + i;
if sample_idx < audio_data.left_channel.len() {
// Sum channels for mono analysis
let sample = audio_data.left_channel[sample_idx] + audio_data.right_channel[sample_idx];
Complex::new(sample, 0.0)
} else {
Complex::new(0.0, 0.0)
}
})
.collect();
// Apply Hann window
for (i, sample) in buffer.iter_mut().enumerate() {
let window = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (fft_size - 1) as f32).cos());
sample.re *= window;
sample.im *= window;
}
fft.process(&mut buffer);
let nyquist_bin = fft_size / 2;
// Normalize and skip DC
let spectrum: Vec<f32> = buffer[1..nyquist_bin]
.iter()
.map(|c| c.norm() / (fft_size as f32))
.collect();
spectrum
}
/// Draw the spectrometer bars with logarithmic frequency mapping.
fn draw_spectrometer(
buffer: &mut ImageBuffer<image::Rgb<u8>, Vec<u8>>,
spectrum: &[f32],
x_offset: u32,
y_offset: u32,
width: u32,
height: u32,
num_bars: usize,
color: image::Rgb<u8>,
sample_rate: u32,
) {
let spacing = 1;
let bar_width = (width - (num_bars as u32 - 1) * spacing) / num_bars as u32;
let bar_width = bar_width.max(1);
// Logarithmic mapping parameters
let min_freq = 20.0f32;
let max_freq = 20000.0f32;
let nyquist = sample_rate as f32 / 2.0;
for i in 0..num_bars {
// Calculate frequency range for this bar (logarithmic)
let f_start = min_freq * (max_freq / min_freq).powf(i as f32 / num_bars as f32);
let f_end = min_freq * (max_freq / min_freq).powf((i + 1) as f32 / num_bars as f32);
// Map frequencies to FFT bin indices
let bin_start = (f_start / nyquist * spectrum.len() as f32) as usize;
let bin_end = (f_end / nyquist * spectrum.len() as f32) as usize;
let bin_end = bin_end.max(bin_start + 1).min(spectrum.len());
// Aggregate magnitude in this frequency range
let mut magnitude = 0.0f32;
if bin_start < spectrum.len() {
magnitude = spectrum[bin_start..bin_end].iter().fold(0.0f32, |acc, &x| acc.max(x));
}
// Apply frequency-dependent boost (higher frequencies are naturally quieter)
// Boost highs by adding a linear factor based on frequency
let freq_factor = 1.0 + (f_start / max_freq) * 5.0;
let mut val = magnitude * freq_factor;
// Dynamic range compression/scaling
val = (val * 20.0).sqrt().min(1.0);
// Noise floor
if val < 0.05 { val = 0.0; }
let bar_height = (val * height as f32) as u32;
let x = x_offset + i as u32 * (bar_width + spacing);
for y in 0..bar_height {
let pixel_y = y_offset + height - 1 - y;
for dx in 0..bar_width {
let pixel_x = x + dx;
if pixel_x < buffer.width() && pixel_y < buffer.height() {
buffer.put_pixel(pixel_x, pixel_y, color);
}
}
}
}
}
/// Draw a single frame of the visualization. /// Draw a single frame of the visualization.
pub fn draw_frame( pub fn draw_frame(
audio_data: &AudioData, audio_data: &AudioData,
@ -182,12 +288,13 @@ pub fn draw_frame(
} }
} }
RenderMode::All => { RenderMode::All => {
let top_height = height / 2; let half_height = height / 2;
let bottom_height = height / 2;
let half_width = width / 2; let half_width = width / 2;
let samples_per_pixel = samples_per_frame as f32 / half_width as f32; let quarter_width = width / 4;
let samples_per_pixel = samples_per_frame as f32 / quarter_width as f32;
let left_center_y = top_height / 2; // Top-left: Left channel waveform
let left_center_y = half_height / 2;
let mut prev_y = left_center_y as i32; let mut prev_y = left_center_y as i32;
for x in 0..half_width { for x in 0..half_width {
let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize; let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize;
@ -195,21 +302,22 @@ pub fn draw_frame(
break; break;
} }
let sample = audio_data.left_channel[sample_index]; let sample = audio_data.left_channel[sample_index];
let y = left_center_y as i32 - (sample * (top_height as f32 * 0.35)) as i32; let y = left_center_y as i32 - (sample * (half_height as f32 * 0.35)) as i32;
draw_line(&mut buffer, x as i32, prev_y, x as i32, y, options.left_color); draw_line(&mut buffer, x as i32, prev_y, x as i32, y, options.left_color);
prev_y = y; prev_y = y;
} }
let right_center_y = top_height / 2; // Top-right: Right channel waveform
let right_center_y = half_height / 2;
let mut prev_y_right = right_center_y as i32; let mut prev_y_right = right_center_y as i32;
for x in 0..half_width { for x in 0..half_width {
let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize; let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize;
if sample_index >= audio_data.left_channel.len() { if sample_index >= audio_data.right_channel.len() {
break; break;
} }
let sample = audio_data.right_channel[sample_index]; let sample = audio_data.right_channel[sample_index];
let y = right_center_y as i32 - (sample * (top_height as f32 * 0.35)) as i32; let y = right_center_y as i32 - (sample * (half_height as f32 * 0.35)) as i32;
draw_line( draw_line(
&mut buffer, &mut buffer,
@ -222,9 +330,10 @@ pub fn draw_frame(
prev_y_right = y; prev_y_right = y;
} }
let xy_center_x = width / 2; // Bottom-left: XY pattern
let xy_center_y = top_height + bottom_height / 2; let xy_center_x = half_width / 2;
let xy_scale = std::cmp::min(half_width, bottom_height) as f32 * 0.35; let xy_center_y = half_height + half_height / 2;
let xy_scale = std::cmp::min(half_width, half_height) as f32 * 0.35;
let xy_samples = (end_sample - start_sample).min(samples_per_frame); let xy_samples = (end_sample - start_sample).min(samples_per_frame);
let mut prev_x = xy_center_x as i32 + (audio_data.left_channel[start_sample] * xy_scale) as i32; let mut prev_x = xy_center_x as i32 + (audio_data.left_channel[start_sample] * xy_scale) as i32;
@ -247,59 +356,94 @@ pub fn draw_frame(
prev_y_xy = y; prev_y_xy = y;
} }
// Bottom-right: Spectrometer
let spec_width = half_width;
let spec_height = half_height;
let spec_x_offset = half_width;
let spec_y_offset = half_height;
let window_size = 1024.min(samples_per_frame);
let spectrum = compute_spectrum(audio_data, start_sample, window_size);
draw_spectrometer(
&mut buffer,
&spectrum,
spec_x_offset,
spec_y_offset,
spec_width,
spec_height,
32,
options.left_color,
audio_data.sample_rate,
);
// Draw grid lines separating quadrants
for x in 0..width { for x in 0..width {
buffer.put_pixel(x, top_height, image::Rgb([40, 40, 40])); buffer.put_pixel(x, half_height, image::Rgb([40, 40, 40]));
} }
for y in 0..top_height { for y in 0..height {
buffer.put_pixel(half_width, y, image::Rgb([40, 40, 40])); buffer.put_pixel(half_width, y, image::Rgb([40, 40, 40]));
} }
} }
RenderMode::Spectrometer => {
let window_size = 1024.min(samples_per_frame);
let spectrum = compute_spectrum(audio_data, start_sample, window_size);
draw_spectrometer(
&mut buffer,
&spectrum,
0,
0,
width,
height,
64,
options.left_color,
audio_data.sample_rate,
);
}
} }
buffer buffer
} }
/// Render frames to PNG files. pub fn stream_frames(
pub fn render_frames(
audio_data: &AudioData, audio_data: &AudioData,
options: &RenderOptions, options: &RenderOptions,
tmp_dir: &Path, encoder: &mut VideoEncoder,
progress_callback: &(impl Fn(f64, usize, usize) + Send + Sync), progress_callback: &(impl Fn(f64, usize, usize) + Send + Sync),
) -> Result<Vec<PathBuf>, anyhow::Error> { ) -> Result<()> {
let total_samples = audio_data.left_channel.len(); let total_samples = audio_data.left_channel.len();
let samples_per_frame = (audio_data.sample_rate / options.fps) as usize; let samples_per_frame = (audio_data.sample_rate / options.fps) as usize;
let total_frames = ((audio_data.duration * options.fps as f64) as usize).max(1); let total_frames = ((audio_data.duration * options.fps as f64) as usize).max(1);
let frame_files: Vec<PathBuf> = (0..total_frames) let num_threads = rayon::current_num_threads();
.map(|i| tmp_dir.join(format!("frame_{:06}.png", i))) let chunk_size = num_threads * 2;
.collect();
use rayon::prelude::*; use rayon::prelude::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let progress = Arc::new(AtomicUsize::new(0)); for chunk_start in (0..total_frames).step_by(chunk_size) {
let chunk_end = (chunk_start + chunk_size).min(total_frames);
let frame_indices: Vec<usize> = (chunk_start..chunk_end).collect();
frame_files let frames: Vec<Result<Vec<u8>>> = frame_indices
.par_iter() .par_iter()
.enumerate() .map(|&frame_idx| {
.try_for_each(|(frame_idx, frame_file): (usize, &PathBuf)| {
let start_sample = std::cmp::min( let start_sample = std::cmp::min(
frame_idx * samples_per_frame, frame_idx * samples_per_frame,
total_samples.saturating_sub(1), total_samples.saturating_sub(1),
); );
let frame = draw_frame(audio_data, start_sample, samples_per_frame, options); let frame = draw_frame(audio_data, start_sample, samples_per_frame, options);
Ok(frame.into_raw())
})
.collect();
let file = std::fs::File::create(frame_file) for frame_result in frames {
.with_context(|| format!("Failed to create frame file: {}", frame_file.display()))?; let frame_data = frame_result?;
let mut writer = std::io::BufWriter::new(file); encoder.write_frame(&frame_data)?;
frame }
.write_to(&mut writer, image::ImageFormat::Png)
.with_context(|| format!("Failed to write frame: {}", frame_file.display()))?;
let current = progress.fetch_add(1, Ordering::SeqCst) + 1; let current = chunk_end;
if current % 30 == 0 || current == total_frames {
progress_callback( progress_callback(
current as f64 / total_frames as f64 * 100.0, current as f64 / total_frames as f64 * 100.0,
current, current,
@ -307,8 +451,6 @@ pub fn render_frames(
); );
} }
Ok::<_, anyhow::Error>(()) Ok(())
})?;
Ok(frame_files)
} }

View File

@ -3,7 +3,9 @@
//! Handles encoding rendered frames into video files using ffmpeg. //! Handles encoding rendered frames into video files using ffmpeg.
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf}; use std::io::Write;
use std::path::Path;
use std::process::{Child, ChildStdin, Command, Stdio};
/// Quality preset for video encoding. /// Quality preset for video encoding.
#[derive(Debug, Clone, Copy, clap::ValueEnum)] #[derive(Debug, Clone, Copy, clap::ValueEnum)]
@ -22,63 +24,70 @@ fn get_quality_settings(quality: VideoQuality) -> (&'static str, &'static str) {
} }
} }
/// Encode video using ffmpeg. pub struct VideoEncoder {
pub fn encode_video( child: Child,
frame_files: &[PathBuf], stdin: ChildStdin,
}
impl VideoEncoder {
pub fn new(
audio_file: &Path, audio_file: &Path,
output_file: &Path, output_file: &Path,
width: u32,
height: u32,
fps: u32, fps: u32,
quality: VideoQuality, quality: VideoQuality,
overwrite: bool, overwrite: bool,
) -> Result<()> { ) -> Result<Self> {
let (video_bitrate, _audio_bitrate) = get_quality_settings(quality); let (video_bitrate, _) = get_quality_settings(quality);
let tmp_dir = frame_files let mut cmd = Command::new("ffmpeg");
.get(0)
.and_then(|p| p.parent())
.unwrap_or(Path::new("."));
let frame_pattern = tmp_dir.join("frame_%06d.png");
let mut cmd = std::process::Command::new("ffmpeg");
if overwrite { if overwrite {
cmd.arg("-y"); cmd.arg("-y");
} }
cmd.args([ cmd.args([
"-framerate", "-f", "rawvideo",
&fps.to_string(), "-pixel_format", "rgb24",
"-i", "-video_size", &format!("{}x{}", width, height),
frame_pattern.to_str().ok_or_else(|| anyhow!("Invalid frame pattern"))?, "-framerate", &fps.to_string(),
"-i", "-i", "-",
audio_file.to_str().ok_or_else(|| anyhow!("Invalid audio file path"))?, "-i", audio_file.to_str().ok_or_else(|| anyhow!("Invalid audio path"))?,
"-r", "-c:v", "libx264",
&fps.to_string(), "-b:v", video_bitrate,
"-c:v", "-c:a", "aac",
"libx264", "-pix_fmt", "yuv420p",
"-b:v",
video_bitrate,
"-c:a",
"copy",
"-pix_fmt",
"yuv420p",
"-shortest", "-shortest",
output_file.to_str().ok_or_else(|| anyhow!("Invalid output path"))?, output_file.to_str().ok_or_else(|| anyhow!("Invalid output path"))?,
]); ]);
let output = cmd let mut child = cmd
.output() .stdin(Stdio::piped())
.with_context(|| "Failed to execute ffmpeg")?; .stderr(Stdio::piped())
.spawn()
.with_context(|| "Failed to spawn ffmpeg")?;
let stdin = child.stdin.take().ok_or_else(|| anyhow!("Failed to open ffmpeg stdin"))?;
Ok(Self { child, stdin })
}
pub fn write_frame(&mut self, data: &[u8]) -> Result<()> {
self.stdin.write_all(data).with_context(|| "Failed to write frame to ffmpeg")
}
pub fn finish(self) -> Result<()> {
drop(self.stdin);
let output = self.child.wait_with_output().context("Failed to wait for ffmpeg")?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("ffmpeg failed: {}", stderr)); return Err(anyhow!("ffmpeg failed: {}", stderr));
} }
println!("Video saved to: {}", output_file.display());
Ok(()) Ok(())
}
} }
/// Clean up temporary files. /// Clean up temporary files.