use std::{path::Path, process, time::Duration};
use avio::{
AudioCodec, BitrateMode, ChapterInfo, ChapterInfoBuilder, EncoderConfig, Pipeline, VideoCodec,
VideoDecoder,
};
fn parse_time(s: &str) -> Result<Duration, String> {
if s.contains(':') {
let parts: Vec<&str> = s.splitn(3, ':').collect();
if parts.len() == 3 {
let h: u64 = parts[0]
.parse()
.map_err(|_| format!("invalid hours in '{s}'"))?;
let m: u64 = parts[1]
.parse()
.map_err(|_| format!("invalid minutes in '{s}'"))?;
let sec: f64 = parts[2]
.parse()
.map_err(|_| format!("invalid seconds in '{s}'"))?;
let total = Duration::from_secs(h * 3600 + m * 60) + Duration::from_secs_f64(sec);
Ok(total)
} else {
Err(format!("invalid time '{s}' (use HH:MM:SS)"))
}
} else {
let secs: f64 = s.parse().map_err(|_| format!("invalid time '{s}'"))?;
Ok(Duration::from_secs_f64(secs))
}
}
fn format_duration(d: Duration) -> String {
let total = d.as_secs();
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
format!("{h:02}:{m:02}:{s:02}")
}
fn main() {
let mut args = std::env::args().skip(1);
let mut input = None::<String>;
let mut output = None::<String>;
let mut title = None::<String>;
let mut artist = None::<String>;
let mut year = None::<String>;
let mut chapters_str = None::<String>;
while let Some(flag) = args.next() {
match flag.as_str() {
"--input" | "-i" => input = Some(args.next().unwrap_or_default()),
"--output" | "-o" => output = Some(args.next().unwrap_or_default()),
"--title" => title = Some(args.next().unwrap_or_default()),
"--artist" => artist = Some(args.next().unwrap_or_default()),
"--year" => year = Some(args.next().unwrap_or_default()),
"--chapters" => chapters_str = Some(args.next().unwrap_or_default()),
other => {
eprintln!("Unknown flag: {other}");
process::exit(1);
}
}
}
let input = input.unwrap_or_else(|| {
eprintln!(
"Usage: write_metadata --input <file> --output <file> \
[--title T] [--artist A] [--year Y] \
[--chapters \"HH:MM:SS=Title,...\"]"
);
process::exit(1);
});
let output = output.unwrap_or_else(|| {
eprintln!("--output is required");
process::exit(1);
});
let raw_chapters: Vec<(Duration, String)> = if let Some(ref s) = chapters_str {
s.split(',')
.filter(|p| !p.is_empty())
.map(|pair| {
let mut parts = pair.splitn(2, '=');
let time_str = parts.next().unwrap_or("").trim();
let title_str = parts.next().unwrap_or("(untitled)").trim().to_string();
let t = parse_time(time_str).unwrap_or_else(|e| {
eprintln!("Error parsing chapter time: {e}");
process::exit(1);
});
(t, title_str)
})
.collect()
} else {
Vec::new()
};
let vid_dec = match VideoDecoder::open(&input).build() {
Ok(d) => d,
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
};
let src_w = vid_dec.width();
let src_h = vid_dec.height();
let in_codec = vid_dec.stream_info().codec_name().to_string();
let total_duration = vid_dec.duration();
let in_name = Path::new(&input)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&input);
let out_name = Path::new(&output)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&output);
println!(
"Input: {in_name} {src_w}×{src_h} {in_codec} {}",
format_duration(total_duration)
);
println!("Output: {out_name}");
println!();
let has_meta = title.is_some() || artist.is_some() || year.is_some();
if has_meta {
println!("Metadata:");
if let Some(ref t) = title {
println!(" title = {t}");
}
if let Some(ref a) = artist {
println!(" artist = {a}");
}
if let Some(ref y) = year {
println!(" year = {y}");
}
println!();
}
let chapters: Vec<ChapterInfo> = raw_chapters
.iter()
.enumerate()
.map(|(i, (start, ch_title))| {
let end = raw_chapters
.get(i + 1)
.map_or(total_duration, |(next_start, _)| *next_start);
#[allow(clippy::cast_possible_wrap)]
let id = i as i64;
let builder: ChapterInfoBuilder = ChapterInfo::builder()
.id(id)
.title(ch_title.clone())
.start(*start)
.end(end);
builder.build()
})
.collect();
if !chapters.is_empty() {
println!("Chapters ({}):", chapters.len());
for ch in &chapters {
let t = ch.title().unwrap_or("(untitled)");
println!(
" {}–{} {t}",
format_duration(ch.start()),
format_duration(ch.end())
);
}
println!();
}
let config = EncoderConfig::builder()
.video_codec(VideoCodec::H264)
.audio_codec(AudioCodec::Aac)
.bitrate_mode(BitrateMode::Crf(23))
.build();
let mut builder = Pipeline::builder().input(&input).output(&output, config);
if let Some(ref t) = title {
builder = builder.metadata("title", t);
}
if let Some(ref a) = artist {
builder = builder.metadata("artist", a);
}
if let Some(ref y) = year {
builder = builder.metadata("date", y);
}
for ch in chapters {
builder = builder.chapter(ch);
}
if let Err(e) = builder
.build()
.unwrap_or_else(|e| {
eprintln!("Error: {e}");
process::exit(1);
})
.run()
{
eprintln!("Error: {e}");
process::exit(1);
}
let size_str = match std::fs::metadata(&output) {
Ok(m) => {
#[allow(clippy::cast_precision_loss)]
let kb = m.len() as f64 / 1024.0;
if kb < 1024.0 {
format!("{kb:.0} KB")
} else {
format!("{:.1} MB", kb / 1024.0)
}
}
Err(_) => "(unknown size)".to_string(),
};
println!("Done. {out_name} {size_str}");
println!("Verify with: cargo run --example probe_info -- {out_name}");
}