initial commit

This commit is contained in:
JorySeverijnse 2026-01-18 15:34:01 +01:00
commit 53140e642a
9 changed files with 2144 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Generated by Cargo
target/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Temporary files
tmp/
temp/
# Build artifacts
*.mp4
*.wav
!test_sine.wav

1167
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

34
Cargo.toml Normal file
View File

@ -0,0 +1,34 @@
[package]
name = "oscilloscope-video-gen"
version = "1.0.0"
edition = "2021"
description = "Generate oscilloscope-style visualizations from audio files"
authors = ["Oscilloscope Video Generator"]
[lib]
path = "src/lib.rs"
[[bin]]
path = "src/main.rs"
name = "oscilloscope-video-gen"
[dependencies]
# Image processing for frame rendering
image = "0.25"
# Parallel processing for faster rendering
rayon = "1.10"
# WAV file handling
hound = "3.5"
# Command line argument parsing
clap = { version = "4.4", features = ["derive"] }
# Error handling
anyhow = "1.0"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

138
README.md Normal file
View File

@ -0,0 +1,138 @@
# Oscilloscope Video Generator
A high-performance Rust tool for generating oscilloscope-style visualizations from audio files. Uses parallel rendering for fast processing.
## Features
- **Multiple visualization modes**: combined, separate, all (L/R + XY)
- **Parallel rendering**: Uses all CPU cores for fast frame generation
- **High quality output**: Supports up to 4K resolution at 60fps
- **Original audio**: Copies audio stream without re-encoding (perfect sync)
- **Customizable**: Colors, resolution, FPS, line thickness
## Installation
### From Source
```bash
cd oscilloscope-video-gen
cargo install --path .
```
### Build Only
```bash
cargo build --release
```
The binary will be at `target/release/oscilloscope-video-gen`.
## Usage
### Basic
```bash
oscilloscope-video-gen -i audio.wav -o video.mp4
```
### Full Options
```bash
oscilloscope-video-gen \
-i audio.wav \
-o video.mp4 \
--width 1920 \
--height 1080 \
--fps 30 \
--mode all \
--quality high \
--left-color "#00ff00" \
--right-color "#00ccff" \
--xy-color "#ff8800" \
--background "#0a0f0a" \
--line-thickness 2 \
--threads 8 \
--overwrite \
--verbose
```
### Options
| Option | Default | Description |
|--------|---------|-------------|
| `-i, --input` | Required | Input WAV file |
| `-o, --output` | Auto-generated | Output MP4 file |
| `--width` | 1920 | Video width in pixels |
| `--height` | 1080 | Video height in pixels |
| `--fps` | 30 | Frames per second |
| `--mode` | all | Visualization mode |
| `--quality` | high | Video quality |
| `--left-color` | #00ff00 | Left channel color (hex) |
| `--right-color` | #00ccff | Right channel color (hex) |
| `--xy-color` | #ff8800 | XY mode color (hex) |
| `--background` | #0a0f0a | Background color (hex) |
| `--show-grid` | true | Show grid lines |
| `--line-thickness` | 2 | Line thickness in pixels |
| `--threads` | All cores | Number of rendering threads |
| `--overwrite` | false | Overwrite output file |
| `--verbose` | false | Enable verbose output |
### Modes
- `combined`: Both channels merged into single waveform
- `separate`: Left on top, Right on bottom
- `all`: Left and Right on top row, XY pattern on bottom
### Quality Presets
| Preset | Video Bitrate |
|--------|---------------|
| low | 2 Mbps |
| medium | 5 Mbps |
| high | 10 Mbps |
## Requirements
- Rust 1.70+
- ffmpeg (for video encoding)
## Building
```bash
# Debug build
cargo build
# Release build (optimized)
cargo build --release
# Build with specific number of threads
cargo build --release --jobs 8
```
## Troubleshooting
### ffmpeg not found
Make sure ffmpeg is installed and in your PATH:
```bash
# Ubuntu/Debian
sudo apt install ffmpeg
# macOS
brew install ffmpeg
# Windows
winget install FFmpeg
```
### Out of memory
For very long audio files, try:
- Lower resolution (`--width 1280 --height 720`)
- Lower FPS (`--fps 24`)
- Fewer threads (`--threads 4`)
## License
MIT

97
src/audio.rs Normal file
View File

@ -0,0 +1,97 @@
//! WAV audio decoding module.
//!
//! Handles reading and decoding WAV files into normalized sample data.
use anyhow::{anyhow, Context, Result};
use hound::WavReader;
use std::path::Path;
/// Normalized audio sample data.
#[derive(Debug, Clone)]
pub struct AudioData {
/// Left channel samples, normalized to [-1.0, 1.0]
pub left_channel: Vec<f32>,
/// Right channel samples, normalized to [-1.0, 1.0]
pub right_channel: Vec<f32>,
/// Sample rate in Hz
pub sample_rate: u32,
/// Duration in seconds
pub duration: f64,
}
impl AudioData {
/// Load and decode a WAV file.
///
/// # 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> {
let mut reader = WavReader::open(file_path)
.with_context(|| format!("Failed to open WAV file: {}", file_path.display()))?;
let spec = reader.spec();
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 {
return Err(anyhow!(
"Unsupported number of channels: {}. Only mono and stereo are supported.",
spec.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 mut left_channel = Vec::with_capacity(total_samples);
let mut right_channel = Vec::with_capacity(total_samples);
for i in 0..total_samples {
let offset = i * spec.channels as usize;
let left_sample = samples[offset] as f32 / 32768.0;
left_channel.push(left_sample);
if spec.channels >= 2 {
let right_sample = samples[offset + 1] as f32 / 32768.0;
right_channel.push(right_sample);
} else {
right_channel.push(left_sample);
}
}
Ok(AudioData {
left_channel,
right_channel,
sample_rate,
duration,
})
}
/// Get the total number of samples.
pub fn len(&self) -> usize {
self.left_channel.len()
}
/// Check if the audio data is empty.
pub fn is_empty(&self) -> bool {
self.left_channel.is_empty()
}
}

25
src/lib.rs Normal file
View File

@ -0,0 +1,25 @@
//! Oscilloscope Video Generator
//!
//! A high-performance tool for generating oscilloscope-style visualizations
//! from audio files. Uses parallel rendering for fast processing.
//!
//! # Example
//!
//! ```no_run
//! use oscilloscope_video_gen::{AudioData, RenderOptions, RenderMode};
//! use std::path::PathBuf;
//!
//! fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let audio = AudioData::from_wav(&PathBuf::from("audio.wav"))?;
//! // ... use the library
//! Ok(())
//! }
//! ```
pub mod audio;
pub mod render;
pub mod video;
pub use audio::AudioData;
pub use render::{RenderMode, RenderOptions};
pub use video::encode_video;

255
src/main.rs Normal file
View File

@ -0,0 +1,255 @@
//! Oscilloscope Video Generator
//!
//! A high-performance tool for generating oscilloscope-style visualizations
//! from audio files. Uses parallel rendering for fast processing.
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use oscilloscope_video_gen::audio::AudioData;
use oscilloscope_video_gen::render::{parse_rgb_hex, render_frames, RenderMode, RenderOptions};
use oscilloscope_video_gen::video::{encode_video, cleanup_tmp_dir, VideoQuality};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputMode {
Combined,
Separate,
All,
}
impl From<OutputMode> for RenderMode {
fn from(val: OutputMode) -> Self {
match val {
OutputMode::Combined => RenderMode::Combined,
OutputMode::Separate => RenderMode::Separate,
OutputMode::All => RenderMode::All,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputQuality {
Low,
Medium,
High,
}
impl From<OutputQuality> for VideoQuality {
fn from(val: OutputQuality) -> Self {
match val {
OutputQuality::Low => VideoQuality::Low,
OutputQuality::Medium => VideoQuality::Medium,
OutputQuality::High => VideoQuality::High,
}
}
}
/// Generate oscilloscope visualizations from audio files
#[derive(Parser, Debug)]
#[command(name = "oscilloscope-video-gen")]
#[command(author, version, about, long_about = None)]
struct Args {
/// Input audio file (WAV)
#[arg(short, long)]
input: PathBuf,
/// Output video file
#[arg(short, long)]
output: Option<PathBuf>,
/// Video width (default: 1920)
#[arg(long, default_value = "1920")]
width: u32,
/// Video height (default: 1080)
#[arg(long, default_value = "1080")]
height: u32,
/// Frames per second (default: 30)
#[arg(long, default_value = "30")]
fps: u32,
/// Display mode: combined, separate, all
#[arg(long, value_enum, default_value = "all")]
mode: OutputMode,
/// Quality: low, medium, high
#[arg(long, value_enum, default_value = "high")]
quality: OutputQuality,
/// Left channel color (RGB hex, default: #00ff00)
#[arg(long, default_value = "#00ff00")]
left_color: String,
/// Right channel color (RGB hex, default: #00ccff)
#[arg(long, default_value = "#00ccff")]
right_color: String,
/// XY mode color (RGB hex, default: #ff8800)
#[arg(long, default_value = "#ff8800")]
xy_color: String,
/// Background color (RGB hex, default: #0a0f0a)
#[arg(long, default_value = "#0a0f0a")]
background: String,
/// Show grid lines
#[arg(long, default_value = "true")]
show_grid: bool,
/// Line thickness (default: 2)
#[arg(long, default_value = "2")]
line_thickness: u32,
/// Number of rendering threads
#[arg(long)]
threads: Option<usize>,
/// Overwrite output file if it exists
#[arg(long, default_value = "false")]
overwrite: bool,
/// Enable verbose output
#[arg(long, default_value = "false")]
verbose: bool,
}
fn main() -> Result<()> {
let args = Args::parse();
// Set number of threads
if let Some(threads) = args.threads {
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build_global()
.unwrap();
}
// Parse colors
let left_color =
parse_rgb_hex(&args.left_color).context("Failed to parse left_color")?;
let right_color =
parse_rgb_hex(&args.right_color).context("Failed to parse right_color")?;
let xy_color = parse_rgb_hex(&args.xy_color).context("Failed to parse xy_color")?;
let background =
parse_rgb_hex(&args.background).context("Failed to parse background")?;
// Create options
let options = RenderOptions {
width: args.width,
height: args.height,
fps: args.fps,
mode: args.mode.into(),
left_color,
right_color,
xy_color,
background,
show_grid: args.show_grid,
line_thickness: args.line_thickness,
};
// Determine output path
let output = match args.output {
Some(path) => path,
None => {
let mut path = args.input.clone();
path.set_extension("mp4");
path
}
};
if args.verbose {
println!("Oscilloscope Video Generator");
println!("============================");
println!("Input: {}", args.input.display());
println!("Output: {}", output.display());
println!("Resolution: {}x{}", args.width, args.height);
println!("FPS: {}", args.fps);
println!("Mode: {:?}", args.mode);
println!("Quality: {:?}", args.quality);
println!("Threads: {:?}", args.threads.unwrap_or_else(|| rayon::current_num_threads()));
println!();
} else {
println!("Oscilloscope Video Generator");
println!("============================");
println!("Input: {}", args.input.display());
println!("Output: {}", output.display());
println!("Resolution: {}x{} @ {}fps", args.width, args.height, args.fps);
println!("Mode: {:?}", args.mode);
println!();
}
// Decode audio
let audio_data = AudioData::from_wav(&args.input)
.with_context(|| format!("Failed to decode audio: {}", args.input.display()))?;
if args.verbose {
println!(
"Audio: {}Hz, {:.2}s duration, {} samples",
audio_data.sample_rate,
audio_data.duration,
audio_data.len()
);
}
// 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
let progress = Arc::new(AtomicUsize::new(0));
let progress_callback = move |percent: f64, current: usize, total: usize| {
let prev = progress.fetch_add(0, Ordering::SeqCst);
if current - prev >= 30 || current == total || current == 1 {
progress.store(current, Ordering::SeqCst);
print!("\rRendering: {:.0}% ({}/{})", percent, current, total);
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
if output.exists() && !args.overwrite {
return Err(anyhow::anyhow!(
"Output file already exists: {}. Use --overwrite to replace it.",
output.display()
));
}
// Encode video
encode_video(
&frame_files,
&args.input,
&output,
args.fps,
args.quality.into(),
args.overwrite,
)
.context("Failed to encode video")?;
// Cleanup
println!("Cleaning up temporary files...");
cleanup_tmp_dir(&tmp_dir);
let file_size = std::fs::metadata(&output)
.map(|m| m.len())
.unwrap_or(0);
println!("\nDone!");
println!("Output: {}", output.display());
println!("Size: {:.2} MB", file_size as f64 / 1_000_000.0);
Ok(())
}

314
src/render.rs Normal file
View File

@ -0,0 +1,314 @@
//! Frame rendering module.
//!
//! Contains all the logic for drawing oscilloscope visualizations.
use crate::audio::AudioData;
use anyhow::{anyhow, Context, Result};
use image::ImageBuffer;
use std::path::{Path, PathBuf};
/// Render mode for the oscilloscope visualization.
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum RenderMode {
/// Both channels merged into a single waveform
Combined,
/// Left channel on top, Right channel on bottom
Separate,
/// Left and Right on top row, XY on bottom
All,
}
/// Rendering options for the visualization.
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub width: u32,
pub height: u32,
pub fps: u32,
pub mode: RenderMode,
pub left_color: image::Rgb<u8>,
pub right_color: image::Rgb<u8>,
pub xy_color: image::Rgb<u8>,
pub background: image::Rgb<u8>,
pub show_grid: bool,
pub line_thickness: u32,
}
/// Draw a line between two points using Bresenham's algorithm.
pub fn draw_line(
buffer: &mut ImageBuffer<image::Rgb<u8>, Vec<u8>>,
x0: i32,
y0: i32,
x1: i32,
y1: i32,
color: image::Rgb<u8>,
) {
let dx = (x1 - x0).abs();
let dy = -(y1 - y0).abs();
let mut x = x0;
let mut y = y0;
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx + dy;
loop {
if x >= 0 && x < buffer.width() as i32 && y >= 0 && y < buffer.height() as i32 {
buffer.put_pixel(x as u32, y as u32, color);
}
if x == x1 && y == y1 {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x += sx;
}
if e2 <= dx {
err += dx;
y += sy;
}
}
}
/// Draw grid lines (graticule).
fn draw_graticule(
buffer: &mut ImageBuffer<image::Rgb<u8>, Vec<u8>>,
primary_color: image::Rgb<u8>,
show_grid: bool,
) {
if !show_grid {
return;
}
let (width, height) = buffer.dimensions();
for x in 0..width {
buffer.put_pixel(x, height / 2, primary_color);
}
for y in 0..height {
buffer.put_pixel(width / 2, y, primary_color);
}
}
/// Parse RGB hex color string.
pub fn parse_rgb_hex(hex: &str) -> Result<image::Rgb<u8>> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Err(anyhow::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 g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| anyhow!("Invalid green component: {}", &hex[2..4]))?;
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| anyhow!("Invalid blue component: {}", &hex[4..6]))?;
Ok(image::Rgb([r, g, b]))
}
/// Draw a single frame of the visualization.
pub fn draw_frame(
audio_data: &AudioData,
start_sample: usize,
samples_per_frame: usize,
options: &RenderOptions,
) -> ImageBuffer<image::Rgb<u8>, Vec<u8>> {
let width = options.width;
let height = options.height;
let mut buffer = ImageBuffer::new(width, height);
for pixel in buffer.pixels_mut() {
*pixel = options.background;
}
if options.show_grid {
draw_graticule(&mut buffer, options.left_color, true);
}
let end_sample = std::cmp::min(start_sample + samples_per_frame, audio_data.left_channel.len());
match options.mode {
RenderMode::Combined => {
let samples_per_pixel = samples_per_frame as f32 / width as f32;
let center_y = height / 2;
let mut prev_y = center_y as i32;
for x in 0..width {
let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize;
if sample_index >= audio_data.left_channel.len() {
break;
}
let sample = (audio_data.left_channel[sample_index]
+ audio_data.right_channel[sample_index])
/ 2.0;
let y = center_y as i32 - (sample * (height as f32 * 0.4)) as i32;
draw_line(&mut buffer, x as i32, prev_y, x as i32, y, options.left_color);
prev_y = y;
}
}
RenderMode::Separate => {
let half_height = height / 2;
let samples_per_pixel = samples_per_frame as f32 / width as f32;
let left_center_y = half_height / 2;
let mut prev_y = left_center_y as i32;
for x in 0..width {
let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize;
if sample_index >= audio_data.left_channel.len() {
break;
}
let sample = audio_data.left_channel[sample_index];
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);
prev_y = y;
}
let right_center_y = half_height + half_height / 2;
let mut prev_y_right = right_center_y as i32;
for x in 0..width {
let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize;
if sample_index >= audio_data.right_channel.len() {
break;
}
let sample = audio_data.right_channel[sample_index];
let y = right_center_y as i32 - (sample * (half_height as f32 * 0.35)) as i32;
draw_line(&mut buffer, x as i32, prev_y_right, x as i32, y, options.right_color);
prev_y_right = y;
}
for x in 0..width {
buffer.put_pixel(x, half_height, image::Rgb([40, 40, 40]));
}
}
RenderMode::All => {
let top_height = height / 2;
let bottom_height = height / 2;
let half_width = width / 2;
let samples_per_pixel = samples_per_frame as f32 / half_width as f32;
let left_center_y = top_height / 2;
let mut prev_y = left_center_y as i32;
for x in 0..half_width {
let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize;
if sample_index >= audio_data.left_channel.len() {
break;
}
let sample = audio_data.left_channel[sample_index];
let y = left_center_y as i32 - (sample * (top_height as f32 * 0.35)) as i32;
draw_line(&mut buffer, x as i32, prev_y, x as i32, y, options.left_color);
prev_y = y;
}
let right_center_y = top_height / 2;
let mut prev_y_right = right_center_y as i32;
for x in 0..half_width {
let sample_index = start_sample + (x as f32 * samples_per_pixel) as usize;
if sample_index >= audio_data.left_channel.len() {
break;
}
let sample = audio_data.right_channel[sample_index];
let y = right_center_y as i32 - (sample * (top_height as f32 * 0.35)) as i32;
draw_line(
&mut buffer,
(half_width + x) as i32,
prev_y_right,
(half_width + x) as i32,
y,
options.right_color,
);
prev_y_right = y;
}
let xy_center_x = width / 2;
let xy_center_y = top_height + bottom_height / 2;
let xy_scale = std::cmp::min(half_width, bottom_height) as f32 * 0.35;
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_y_xy = xy_center_y as i32
- (audio_data.right_channel[start_sample] * xy_scale) as i32;
for i in 1..xy_samples {
let sample_idx = start_sample + i;
if sample_idx >= audio_data.left_channel.len() {
break;
}
let x = xy_center_x as i32
+ (audio_data.left_channel[sample_idx] * xy_scale) as i32;
let y = xy_center_y as i32
- (audio_data.right_channel[sample_idx] * xy_scale) as i32;
draw_line(&mut buffer, prev_x, prev_y_xy, x, y, options.xy_color);
prev_x = x;
prev_y_xy = y;
}
for x in 0..width {
buffer.put_pixel(x, top_height, image::Rgb([40, 40, 40]));
}
for y in 0..top_height {
buffer.put_pixel(half_width, y, image::Rgb([40, 40, 40]));
}
}
}
buffer
}
/// Render frames to PNG files.
pub fn render_frames(
audio_data: &AudioData,
options: &RenderOptions,
tmp_dir: &Path,
progress_callback: &(impl Fn(f64, usize, usize) + Send + Sync),
) -> Result<Vec<PathBuf>, anyhow::Error> {
let total_samples = audio_data.left_channel.len();
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 frame_files: Vec<PathBuf> = (0..total_frames)
.map(|i| tmp_dir.join(format!("frame_{:06}.png", i)))
.collect();
use rayon::prelude::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let progress = Arc::new(AtomicUsize::new(0));
frame_files
.par_iter()
.enumerate()
.try_for_each(|(frame_idx, frame_file): (usize, &PathBuf)| {
let start_sample = std::cmp::min(
frame_idx * samples_per_frame,
total_samples.saturating_sub(1),
);
let frame = draw_frame(audio_data, start_sample, samples_per_frame, options);
let file = std::fs::File::create(frame_file)
.with_context(|| format!("Failed to create frame file: {}", frame_file.display()))?;
let mut writer = std::io::BufWriter::new(file);
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;
if current % 30 == 0 || current == total_frames {
progress_callback(
current as f64 / total_frames as f64 * 100.0,
current,
total_frames,
);
}
Ok::<_, anyhow::Error>(())
})?;
Ok(frame_files)
}

89
src/video.rs Normal file
View File

@ -0,0 +1,89 @@
//! Video encoding module.
//!
//! Handles encoding rendered frames into video files using ffmpeg.
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
/// Quality preset for video encoding.
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum VideoQuality {
Low,
Medium,
High,
}
/// Get ffmpeg quality settings.
fn get_quality_settings(quality: VideoQuality) -> (&'static str, &'static str) {
match quality {
VideoQuality::Low => ("2M", "128k"),
VideoQuality::Medium => ("5M", "192k"),
VideoQuality::High => ("10M", "320k"),
}
}
/// Encode video using ffmpeg.
pub fn encode_video(
frame_files: &[PathBuf],
audio_file: &Path,
output_file: &Path,
fps: u32,
quality: VideoQuality,
overwrite: bool,
) -> Result<()> {
let (video_bitrate, _audio_bitrate) = get_quality_settings(quality);
let tmp_dir = frame_files
.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 {
cmd.arg("-y");
}
cmd.args([
"-framerate",
&fps.to_string(),
"-i",
frame_pattern.to_str().ok_or_else(|| anyhow!("Invalid frame pattern"))?,
"-i",
audio_file.to_str().ok_or_else(|| anyhow!("Invalid audio file path"))?,
"-r",
&fps.to_string(),
"-c:v",
"libx264",
"-b:v",
video_bitrate,
"-c:a",
"copy",
"-pix_fmt",
"yuv420p",
"-shortest",
output_file.to_str().ok_or_else(|| anyhow!("Invalid output path"))?,
]);
let output = cmd
.output()
.with_context(|| "Failed to execute ffmpeg")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("ffmpeg failed: {}", stderr));
}
println!("Video saved to: {}", output_file.display());
Ok(())
}
/// Clean up temporary files.
pub fn cleanup_tmp_dir(tmp_dir: &Path) {
if tmp_dir.exists() {
let _ = std::fs::remove_dir_all(tmp_dir);
}
}