use std::fs::File;
use std::io::{BufReader, Read};
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize;
use muxide::api::{MuxerBuilder, Muxer, VideoCodec, AudioCodec, AacProfile, Metadata};
use muxide::assert_invariant;
fn read_hex_bytes(contents: &str) -> Vec<u8> {
let hex: String = contents.chars().filter(|c| !c.is_whitespace()).collect();
assert!(hex.len() % 2 == 0, "hex must have even length");
let mut out = Vec::with_capacity(hex.len() / 2);
for i in (0..hex.len()).step_by(2) {
let byte = u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex");
out.push(byte);
}
out
}
#[derive(Parser)]
#[command(name = "muxide")]
#[command(version, about, long_about)]
#[command(propagate_version = true)]
#[command(arg_required_else_help = true)]
struct Cli {
#[arg(short, long)]
verbose: bool,
#[arg(long)]
json: bool,
#[arg(long)]
no_progress: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
#[command(alias = "m")]
Mux {
#[arg(short, long)]
video: Option<PathBuf>,
#[arg(short, long)]
audio: Option<PathBuf>,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
video_codec: Option<VideoCodec>,
#[arg(long)]
width: Option<u32>,
#[arg(long)]
height: Option<u32>,
#[arg(long)]
fps: Option<f64>,
#[arg(long)]
audio_codec: Option<AudioCodec>,
#[arg(long)]
sample_rate: Option<u32>,
#[arg(long)]
channels: Option<u8>,
#[arg(long)]
fragmented: bool,
#[arg(long, default_value = "2000")]
fragment_duration_ms: u32,
#[arg(long)]
title: Option<String>,
#[arg(long)]
language: Option<String>,
#[arg(long)]
creation_time: Option<String>,
},
#[command(alias = "v")]
Validate {
#[arg(short, long)]
video: Option<PathBuf>,
#[arg(short, long)]
audio: Option<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
},
#[command(alias = "i")]
Info {
input: PathBuf,
},
}
#[derive(Debug, Serialize)]
struct MuxStats {
video_frames: u64,
audio_frames: u64,
total_bytes: u64,
duration_ms: u64,
}
impl MuxStats {
fn new() -> Self {
Self {
video_frames: 0,
audio_frames: 0,
total_bytes: 0,
duration_ms: 0,
}
}
}
struct ProgressReporter {
progress: Option<ProgressBar>,
stats: MuxStats,
}
impl ProgressReporter {
fn new(enabled: bool) -> Self {
let progress = if enabled {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{elapsed_precise}] {msg} ({bytes_per_sec})"
).unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
);
pb.set_message("Muxing frames...");
Some(pb)
} else {
None
};
Self {
progress,
stats: MuxStats::new(),
}
}
fn update_video_frame(&mut self) {
self.stats.video_frames += 1;
if let Some(pb) = &self.progress {
pb.set_message(format!("Muxing frames... (video: {}, audio: {})",
self.stats.video_frames, self.stats.audio_frames));
}
}
fn update_audio_frame(&mut self) {
self.stats.audio_frames += 1;
if let Some(pb) = &self.progress {
pb.set_message(format!("Muxing frames... (video: {}, audio: {})",
self.stats.video_frames, self.stats.audio_frames));
}
}
fn update_bytes(&mut self, bytes: u64) {
self.stats.total_bytes += bytes;
if let Some(pb) = &self.progress {
pb.set_length(self.stats.total_bytes);
}
}
fn finish(self) -> Result<MuxStats> {
if let Some(pb) = self.progress {
pb.finish_with_message("Muxing complete!");
}
Ok(self.stats)
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
if cli.verbose {
eprintln!("Muxide v{} - Starting...", env!("CARGO_PKG_VERSION"));
}
match cli.command {
Commands::Mux {
video,
audio,
output,
video_codec,
width,
height,
fps,
audio_codec,
sample_rate,
channels,
fragmented,
fragment_duration_ms,
title,
language,
creation_time,
} => {
let progress = ProgressReporter::new(!cli.no_progress);
mux_command(
video, audio, output, video_codec, width, height, fps,
audio_codec, sample_rate, channels, fragmented, fragment_duration_ms,
title, language, creation_time,
progress, cli.verbose, cli.json,
)
}
Commands::Validate { video, audio, output } => {
validate_command(video, audio, output, cli.verbose, cli.json)
}
Commands::Info { input } => {
info_command(input, cli.verbose, cli.json)
}
}
}
fn mux_command(
video: Option<PathBuf>,
audio: Option<PathBuf>,
output: PathBuf,
video_codec: Option<VideoCodec>,
width: Option<u32>,
height: Option<u32>,
fps: Option<f64>,
audio_codec: Option<AudioCodec>,
sample_rate: Option<u32>,
channels: Option<u8>,
fragmented: bool,
_fragment_duration_ms: u32,
title: Option<String>,
language: Option<String>,
creation_time: Option<String>,
mut progress: ProgressReporter,
verbose: bool,
json: bool,
) -> Result<()> {
if verbose {
eprintln!("Setting up muxer...");
}
if video.is_none() && audio.is_none() {
anyhow::bail!("At least one of --video or --audio must be specified");
}
if video.is_some() {
assert_invariant!(
width.is_some() && height.is_some() && fps.is_some(),
"Video parameters must be complete when video input is provided",
"cli::mux_command"
);
}
if audio.is_some() {
assert_invariant!(
sample_rate.is_some() && channels.is_some(),
"Audio parameters must be complete when audio input is provided",
"cli::mux_command"
);
}
if video.is_some() && (width.is_none() || height.is_none() || fps.is_none()) {
anyhow::bail!("Video parameters --width, --height, and --fps are required when using --video");
}
let video_path = video.clone();
let audio_path = audio.clone();
let output_file = File::create(&output)
.with_context(|| format!("Failed to create output file: {}", output.display()))?;
let mut builder = if fragmented {
MuxerBuilder::new(output_file)
} else {
MuxerBuilder::new(output_file)
};
if let (Some(_video), Some(width), Some(height), Some(fps)) = (&video, width, height, fps) {
let width = width;
let height = height;
let fps = fps;
let codec = video_codec.unwrap_or(VideoCodec::H264);
assert_invariant!(
matches!(codec, VideoCodec::H264 | VideoCodec::H265 | VideoCodec::Av1),
"Video codec must be one of the supported variants",
"cli::mux_command"
);
builder = builder.video(codec, width, height, fps);
assert_invariant!(
width >= 320 && height >= 240 && width <= 4096 && height <= 2160,
"Video dimensions must be within reasonable limits (320x240 to 4096x2160)",
"cli::mux_command"
);
assert_invariant!(
fps > 0.0 && fps <= 120.0,
"Frame rate must be positive and within reasonable limits",
"cli::mux_command"
);
if verbose {
eprintln!("Configured video: {} {}x{} @ {}fps",
codec, width, height, fps);
}
}
if let (Some(_audio), Some(sample_rate), Some(channels)) = (&audio, sample_rate, channels) {
let sample_rate = sample_rate;
let channels = channels;
let codec = audio_codec.unwrap_or(AudioCodec::Aac(AacProfile::Lc));
assert_invariant!(
matches!(codec, AudioCodec::Aac(_) | AudioCodec::Opus),
"Audio codec must be one of the supported variants",
"cli::mux_command"
);
builder = builder.audio(codec, sample_rate, channels as u16);
assert_invariant!(
sample_rate > 0 && sample_rate <= 192000,
"Audio sample rate must be positive and within reasonable limits",
"cli::mux_command"
);
assert_invariant!(
channels > 0 && channels <= 8,
"Audio channels must be positive and within reasonable limits",
"cli::mux_command"
);
if verbose {
eprintln!("Configured audio: {} {}Hz {}ch",
match codec {
AudioCodec::Aac(profile) => format!("AAC-{}", profile),
AudioCodec::Opus => "Opus".to_string(),
AudioCodec::None => "None".to_string(),
},
sample_rate, channels);
}
}
if let Some(title) = title {
builder = builder.with_metadata(Metadata::new().with_title(title));
}
if let Some(language) = language {
builder = builder.set_language(language);
}
if let Some(_creation_time) = creation_time {
eprintln!("Warning: creation_time not yet implemented");
}
let mut muxer = builder.build()
.with_context(|| "Failed to build muxer")?;
if let Some(video_path) = video_path {
process_video_frames(&video_path, &mut muxer, &mut progress, verbose)?;
}
if let Some(audio_path) = audio_path {
process_audio_frames(&audio_path, &mut muxer, &mut progress, verbose)?;
}
if verbose {
eprintln!("Finalizing MP4...");
}
assert_invariant!(
video.is_some() || audio.is_some(),
"At least one media stream (video or audio) must be configured",
"cli::mux_command"
);
assert_invariant!(
output.metadata().is_ok(),
"Output file path must be writable",
"cli::mux_command"
);
muxer.finish()
.with_context(|| "Failed to finalize MP4")?;
let stats = progress.finish()?;
assert_invariant!(
stats.total_bytes > 0,
"Final output must have non-zero size",
"cli::mux_command"
);
if json {
println!("{}", serde_json::to_string_pretty(&stats)?);
} else {
println!("✅ Muxing complete!");
println!(" Video frames: {}", stats.video_frames);
println!(" Audio frames: {}", stats.audio_frames);
println!(" Total size: {} bytes", stats.total_bytes);
println!(" Output: {}", output.display());
}
Ok(())
}
fn process_video_frames(
video_path: &PathBuf,
muxer: &mut Muxer<File>,
progress: &mut ProgressReporter,
verbose: bool,
) -> Result<()> {
if verbose {
eprintln!("Processing video frames from: {}", video_path.display());
}
let file = File::open(video_path)
.with_context(|| format!("Failed to open video file: {}", video_path.display()))?;
let mut reader = BufReader::new(file);
let mut hex_content = String::new();
reader.read_to_string(&mut hex_content)
.with_context(|| "Failed to read video data")?;
let data = read_hex_bytes(&hex_content);
muxer.write_video(0.0, &data, true)
.with_context(|| "Failed to write video frame")?;
progress.update_video_frame();
progress.update_bytes(data.len() as u64);
Ok(())
}
fn process_audio_frames(
audio_path: &PathBuf,
muxer: &mut Muxer<File>,
progress: &mut ProgressReporter,
verbose: bool,
) -> Result<()> {
if verbose {
eprintln!("Processing audio frames from: {}", audio_path.display());
}
let file = File::open(audio_path)
.with_context(|| format!("Failed to open audio file: {}", audio_path.display()))?;
let mut reader = BufReader::new(file);
let mut hex_content = String::new();
reader.read_to_string(&mut hex_content)
.with_context(|| "Failed to read audio data")?;
let data = read_hex_bytes(&hex_content);
muxer.write_audio(0.0, &data)
.with_context(|| "Failed to write audio frame")?;
progress.update_audio_frame();
progress.update_bytes(data.len() as u64);
Ok(())
}
fn validate_command(
_video: Option<PathBuf>,
_audio: Option<PathBuf>,
output: Option<PathBuf>,
verbose: bool,
json: bool,
) -> Result<()> {
if verbose {
eprintln!("Running validation...");
}
let report = serde_json::json!({
"status": "not_implemented",
"message": "Validation command not yet implemented"
});
if let Some(output_path) = output {
std::fs::write(&output_path, serde_json::to_string_pretty(&report)?)?;
if !json {
println!("Validation report written to: {}", output_path.display());
}
} else if json {
println!("{}", serde_json::to_string(&report)?);
} else {
println!("Validation: Not yet implemented");
}
Ok(())
}
fn info_command(
input: PathBuf,
verbose: bool,
json: bool,
) -> Result<()> {
if verbose {
eprintln!("Analyzing file: {}", input.display());
}
let info = serde_json::json!({
"file": input.display().to_string(),
"status": "not_implemented",
"message": "Info command not yet implemented"
});
if json {
println!("{}", serde_json::to_string(&info)?);
} else {
println!("File info: Not yet implemented");
println!("File: {}", input.display());
}
Ok(())
}