initial commit
This commit is contained in:
commit
53140e642a
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
1167
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
Cargo.toml
Normal file
34
Cargo.toml
Normal 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
138
README.md
Normal 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
97
src/audio.rs
Normal 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
25
src/lib.rs
Normal 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
255
src/main.rs
Normal 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
314
src/render.rs
Normal 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
89
src/video.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user