use anyhow::{Context, Result};
use colored::Colorize;
use oximedia_compat_ffmpeg::{parse_and_translate, DiagnosticKind, ParsedFilter, TranscodeJob};
use std::path::PathBuf;
use tracing::warn;
use crate::progress::ProgressFormat;
use crate::transcode::{self, TranscodeOptions};
pub async fn run(args: Vec<String>) -> Result<()> {
if args.is_empty() {
print_ffcompat_help();
return Ok(());
}
let explain_mode = args.iter().any(|a| a == "--explain");
let dry_run = args
.iter()
.any(|a| a == "--dry-run" || a == "--plan" || a == "-dry-run");
let filtered_args: Vec<String> = args
.into_iter()
.filter(|a| a != "--dry-run" && a != "--plan" && a != "-dry-run" && a != "--explain")
.collect();
let result = parse_and_translate(&filtered_args);
for diag in &result.diagnostics {
match &diag.kind {
DiagnosticKind::PatentCodecSubstituted { from, to } => {
eprintln!(
"{} Codec '{}' is a patent codec. Using '{}' instead.",
"warning:".yellow().bold(),
from,
to
);
}
DiagnosticKind::UnknownOptionIgnored { option } => {
eprintln!(
"{} Option '{}' not supported. Ignoring.",
"warning:".yellow().bold(),
option
);
}
DiagnosticKind::FilterNotSupported { filter } => {
eprintln!(
"{} Filter '{}' not supported. Skipping.",
"warning:".yellow().bold(),
filter
);
}
DiagnosticKind::UnsupportedFeature { description } => {
eprintln!("{} {}.", "warning:".yellow().bold(), description);
}
DiagnosticKind::Info { message } => {
println!("{} {}", "info:".cyan(), message);
}
DiagnosticKind::Error { message } => {
eprintln!("{} {}", "error:".red().bold(), message);
if let Some(hint) = &diag.suggestion {
eprintln!(" {} {}", "hint:".yellow(), hint);
}
}
DiagnosticKind::Warning { message } => {
eprintln!("{} {}", "warning:".yellow().bold(), message);
if let Some(hint) = &diag.suggestion {
eprintln!(" {} {}", "hint:".cyan(), hint);
}
}
}
}
if result.has_errors() {
anyhow::bail!("translation failed with errors; see diagnostics above");
}
if explain_mode {
print_explain_table(&result.jobs);
return Ok(());
}
println!(
"\n{} {} transcode job(s) translated from FFmpeg arguments:",
"âś“".green(),
result.jobs.len()
);
for (idx, job) in result.jobs.iter().enumerate() {
println!("\n{} Job {}:", "─".repeat(4).dimmed(), idx + 1);
println!(" input: {}", job.input_path.cyan());
println!(" output: {}", job.output_path.cyan());
if let Some(vc) = &job.video_codec {
println!(" video codec: {}", vc.green());
}
if let Some(ac) = &job.audio_codec {
println!(" audio codec: {}", ac.green());
}
if let Some(vb) = &job.video_bitrate {
println!(" video bitrate: {}", vb);
}
if let Some(ab) = &job.audio_bitrate {
println!(" audio bitrate: {}", ab);
}
if let Some(crf) = job.crf {
println!(" crf: {:.1}", crf);
}
if !job.video_filters.is_empty() {
println!(" video filters: {} filter(s)", job.video_filters.len());
}
if !job.audio_filters.is_empty() {
println!(" audio filters: {} filter(s)", job.audio_filters.len());
}
if let Some(seek) = &job.seek {
println!(" seek: {}", seek);
}
if let Some(dur) = &job.duration {
println!(" duration: {}", dur);
}
if !job.metadata.is_empty() {
for (k, v) in &job.metadata {
println!(" metadata: {}={}", k, v);
}
}
if job.no_video {
println!(" {}", "no video".dimmed());
}
if job.no_audio {
println!(" {}", "no audio".dimmed());
}
if job.overwrite {
println!(" overwrite: yes");
}
if !job.map.is_empty() {
println!(" map: {} stream selector(s)", job.map.len());
}
if dry_run {
println!(" {}", "[dry-run: skipping execution]".yellow().italic());
}
}
if dry_run {
println!("\n{} Dry-run mode — no files were written.", "note:".cyan());
return Ok(());
}
for (idx, job) in result.jobs.iter().enumerate() {
eprintln!(
"\n{} Executing job {}/{}: {} → {}",
"oximedia-ff:".green().bold(),
idx + 1,
result.jobs.len(),
job.input_path.cyan(),
job.output_path.cyan()
);
execute_job(job).await.with_context(|| {
format!(
"job {} failed: {} → {}",
idx + 1,
job.input_path,
job.output_path
)
})?;
}
Ok(())
}
async fn execute_job(job: &TranscodeJob) -> Result<()> {
if !job.overwrite && std::path::Path::new(&job.output_path).exists() {
anyhow::bail!(
"Output file '{}' already exists. Pass -y to overwrite.",
job.output_path
);
}
let vf_string = build_filter_string(&job.video_filters);
let af_string = build_filter_string(&job.audio_filters);
let scale_from_filters = extract_scale_filter(&job.video_filters);
let video_codec = match job.video_codec.as_deref() {
Some("copy") | None if job.no_video => None,
Some(vc) => Some(vc.to_string()),
None => None,
};
let audio_codec = match job.audio_codec.as_deref() {
Some("copy") | None if job.no_audio => None,
Some(ac) => Some(ac.to_string()),
None => None,
};
let crf = job.crf.map(|c| c.round() as u32);
let options = TranscodeOptions {
input: PathBuf::from(&job.input_path),
output: PathBuf::from(&job.output_path),
preset_name: None,
video_codec,
audio_codec,
video_bitrate: job.video_bitrate.clone(),
audio_bitrate: job.audio_bitrate.clone(),
scale: scale_from_filters,
video_filter: vf_string,
audio_filter: af_string,
start_time: job.seek.clone(),
duration: job.duration.clone(),
framerate: None,
preset: "medium".to_string(),
two_pass: false,
crf,
threads: num_cpus(),
overwrite: job.overwrite,
resume: false,
progress_format: ProgressFormat::Plain,
};
transcode::transcode(options).await
}
fn build_filter_string(filters: &[ParsedFilter]) -> Option<String> {
let parts: Vec<String> = filters
.iter()
.filter_map(|f| match f {
ParsedFilter::Scale { w, h } => Some(format!("scale={}:{}", w, h)),
ParsedFilter::Fps { rate } => Some(format!("fps={}", rate)),
ParsedFilter::HFlip => Some("hflip".to_string()),
ParsedFilter::VFlip => Some("vflip".to_string()),
ParsedFilter::Deinterlace => Some("yadif".to_string()),
ParsedFilter::Rotate { angle } => Some(format!("rotate={}", angle)),
ParsedFilter::Crop { w, h, x, y } => Some(format!("crop={}:{}:{}:{}", w, h, x, y)),
ParsedFilter::ColorCorrect {
brightness,
contrast,
saturation,
} => Some(format!(
"eq=brightness={}:contrast={}:saturation={}",
brightness, contrast, saturation
)),
ParsedFilter::Lut3d { file } => Some(format!("lut3d=file={}", file)),
ParsedFilter::SubtitleBurnIn { file } => Some(format!("subtitles=filename={}", file)),
ParsedFilter::LoudNorm {
integrated,
true_peak,
lra,
} => Some(format!(
"loudnorm=I={}:TP={}:LRA={}",
integrated, true_peak, lra
)),
ParsedFilter::Volume { factor } => Some(format!("volume={}", factor)),
ParsedFilter::Resample { sample_rate } => Some(format!("aresample={}", sample_rate)),
ParsedFilter::Compressor { threshold, ratio } => Some(format!(
"acompressor=threshold={}:ratio={}",
threshold, ratio
)),
ParsedFilter::Passthrough => None,
ParsedFilter::Unknown { name, args } => {
warn!(
"Skipping unsupported filter '{}' (args: '{}') during execution.",
name, args
);
eprintln!(
"{} Skipping unsupported filter '{}' during execution.",
"warning:".yellow().bold(),
name
);
None
}
})
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join(","))
}
}
fn extract_scale_filter(filters: &[ParsedFilter]) -> Option<String> {
filters.iter().find_map(|f| {
if let ParsedFilter::Scale { w, h } = f {
Some(format!("{}:{}", w, h))
} else {
None
}
})
}
fn num_cpus() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
}
fn print_explain_table(jobs: &[TranscodeJob]) {
println!(
"{} Translation table (--explain mode):",
"oximedia-ff:".cyan().bold()
);
println!();
for (idx, job) in jobs.iter().enumerate() {
println!("{} Job {} of {}", "──".dimmed(), idx + 1, jobs.len());
println!(" {:20} = {}", "-i".yellow(), job.input_path);
println!(" {:20} = {}", "<output>".yellow(), job.output_path);
if let Some(vc) = &job.video_codec {
println!(" {:20} = {}", "-c:v".yellow(), vc);
}
if let Some(ac) = &job.audio_codec {
println!(" {:20} = {}", "-c:a".yellow(), ac);
}
if let Some(vb) = &job.video_bitrate {
println!(" {:20} = {}", "-b:v".yellow(), vb);
}
if let Some(ab) = &job.audio_bitrate {
println!(" {:20} = {}", "-b:a".yellow(), ab);
}
if let Some(crf) = job.crf {
println!(" {:20} = {:.1}", "-crf".yellow(), crf);
}
if !job.video_filters.is_empty() {
println!(
" {:20} = {} filter(s)",
"-vf".yellow(),
job.video_filters.len()
);
}
if !job.audio_filters.is_empty() {
println!(
" {:20} = {} filter(s)",
"-af".yellow(),
job.audio_filters.len()
);
}
if let Some(seek) = &job.seek {
println!(" {:20} = {}", "-ss".yellow(), seek);
}
if let Some(dur) = &job.duration {
println!(" {:20} = {}", "-t".yellow(), dur);
}
if let Some(fmt) = &job.format {
println!(" {:20} = {}", "-f".yellow(), fmt);
}
if let Some(preset) = &job.preset {
println!(" {:20} = {}", "-preset".yellow(), preset);
}
if let Some(tune) = &job.tune {
println!(" {:20} = {}", "-tune".yellow(), tune);
}
if let Some(profile) = &job.profile {
println!(" {:20} = {}", "-profile:v".yellow(), profile);
}
if let Some(pass) = job.pass {
println!(" {:20} = {}", "-pass".yellow(), pass);
}
if job.overwrite {
println!(" {:20} = yes", "-y".yellow());
}
if job.no_video {
println!(" {:20} = yes", "-vn".yellow());
}
if job.no_audio {
println!(" {:20} = yes", "-an".yellow());
}
if !job.map.is_empty() {
println!(" {:20} = {} selector(s)", "-map".yellow(), job.map.len());
}
for (k, v) in &job.metadata {
println!(" {:20} = {}={}", "-metadata".yellow(), k, v);
}
if !job.map_metadata.is_empty() {
println!(
" {:20} = {} directive(s)",
"-map_metadata".yellow(),
job.map_metadata.len()
);
}
if let Some(hw) = &job.hwaccel {
println!(
" {:20} = {} ({})",
"-hwaccel".yellow(),
hw.backend,
hw.description
);
}
if !job.muxer_options.is_empty() {
println!(
" {:20} = {} option(s)",
"muxer opts".yellow(),
job.muxer_options.len()
);
}
println!();
}
println!(
"{} Use --dry-run to also skip execution without the translation details.",
"note:".cyan()
);
}
fn print_ffcompat_help() {
println!("{}", "OxiMedia FFmpeg-compatible interface".cyan().bold());
println!();
println!("Usage:");
println!(" oximedia ffcompat [FFmpeg arguments...]");
println!(" oximedia ff [FFmpeg arguments...]");
println!(" oximedia-ff [FFmpeg arguments...]");
println!();
println!("Options (OxiMedia extensions):");
println!(" --dry-run / --plan Print what would be done without executing.");
println!(" --explain Print the arg→field translation table and exit.");
println!();
println!("Examples:");
println!(" oximedia ff -i input.mkv -c:v libaom-av1 -crf 28 -c:a libopus output.webm");
println!(" oximedia ff -y -i input.mkv -vf scale=1280:720 -b:v 2M output.webm");
println!(
" oximedia ff -i input.mp4 -c:v libx264 -c:a aac output.webm # patent codecs auto-substituted"
);
println!();
println!(
"{}",
"Note: Only patent-free codecs are supported (AV1, VP9, VP8, Opus, Vorbis, FLAC).".yellow()
);
println!(
"{}",
" Patent-encumbered codecs (H.264, AAC, MP3, etc.) are automatically substituted."
.yellow()
);
}