#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ffai_core::engine::{AsrOptions, DetectOptions, OcrOptions, Task, TtsOptions, VlmOptions};
use ffai_core::registry::EngineRegistry;
#[derive(Parser)]
#[command(
name = "ffai",
version,
about = "FFai — the AI media toolkit, remade with rust",
long_about = "FFai — OCR, ASR, TTS, and vision-language understanding in one \
pure-Rust toolkit.\n\nComponents: Mercury (voice), Carmenta (OCR), \
Argus (vision). Engines are swappable per task, like codecs in ffmpeg."
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Engines {
#[arg(long)]
task: Option<String>,
},
Models {
#[arg(long, default_value = "models")]
dir: PathBuf,
#[arg(long)]
fetch: Option<String>,
},
Asr {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
engine: Option<String>,
#[arg(long)]
language: Option<String>,
#[arg(long)]
word_timestamps: bool,
#[arg(long)]
diarize: bool,
#[arg(long)]
max_speakers: Option<usize>,
#[arg(long, default_value_t = 0.80)]
diarize_threshold: f32,
#[arg(long)]
vad: bool,
#[arg(long, conflicts_with = "vad")]
no_vad: bool,
#[arg(long, default_value_t = 0.5)]
vad_threshold: f32,
#[arg(long, default_value_t = 30.0)]
vad_chunk_secs: f32,
},
Tts {
text: String,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
engine: Option<String>,
#[arg(long)]
voice: Option<String>,
#[arg(long, default_value_t = 1.0)]
speed: f32,
#[arg(long)]
noise_scale: Option<f32>,
#[arg(long)]
noise_w: Option<f32>,
#[arg(long, default_value_t = 0)]
seed: u64,
#[arg(long, default_value_t = 0.2)]
sentence_silence: f32,
},
Ocr {
#[arg(short, long)]
input: PathBuf,
#[arg(long)]
engine: Option<String>,
#[arg(long)]
language: Vec<String>,
#[arg(long)]
live: bool,
#[arg(long, default_value_t = 3.0)]
fps: f64,
#[arg(long, default_value_t = 0.0005)]
change_fraction: f32,
#[arg(long, default_value_t = 1)]
sample_every: usize,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
watch: Option<f64>,
},
Detect {
#[arg(short, long)]
input: PathBuf,
#[arg(long)]
engine: Option<String>,
#[arg(long, default_value_t = 0.25)]
conf: f32,
#[arg(long)]
iou: Option<f32>,
#[arg(long, default_value_t = 300)]
max_det: usize,
#[arg(long)]
classes: Vec<u32>,
#[arg(short, long)]
output: Option<PathBuf>,
},
Caption {
#[arg(short, long)]
input: PathBuf,
#[arg(long)]
prompt: Option<String>,
#[arg(long)]
engine: Option<String>,
},
Bench {
task: String,
#[arg(long)]
corpus: PathBuf,
#[arg(long, default_value = "corpora/references.toml")]
refs: PathBuf,
#[arg(long)]
engine: Option<String>,
#[arg(long = "only")]
only: Vec<String>,
#[arg(long)]
baseline_only: bool,
#[arg(long, default_value_t = 3)]
runs: usize,
#[arg(long, default_value = "bench/ledger.jsonl")]
ledger: PathBuf,
},
}
fn build_registry() -> EngineRegistry {
let mut reg = EngineRegistry::new();
ffai_mercury::register(&mut reg);
ffai_carmenta::register(&mut reg);
ffai_diana::register(&mut reg);
ffai_argus::register(&mut reg);
reg
}
fn match_candle_threads() {
if std::env::var_os("RAYON_NUM_THREADS").is_some() {
return;
}
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
let n = std::env::var("FFAI_DIANA_THREADS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or_else(|| (cores / 6).clamp(3, 6));
#[allow(unsafe_code)]
unsafe {
std::env::set_var("RAYON_NUM_THREADS", n.to_string());
}
}
fn main() -> Result<()> {
match_candle_threads();
let cli = Cli::parse();
let reg = build_registry();
match cli.cmd {
Cmd::Engines { task } => {
let filter = task
.map(|t| Task::from_str(&t).map_err(anyhow::Error::msg))
.transpose()?;
println!("{:<6} {:<16} {:<13} DESCRIPTION", "TASK", "ENGINE", "STATUS");
for info in reg.list() {
if filter.is_some_and(|t| t != info.task) {
continue;
}
println!(
"{:<6} {:<16} {:<13} {}",
info.task.to_string(),
info.name,
info.status.to_string(),
info.description
);
}
}
Cmd::Models { dir, fetch } => {
let manifests = ffai_models::load_dir(&dir)
.with_context(|| format!("reading manifests from {}", dir.display()))?;
if let Some(name) = fetch {
let manifest = manifests
.iter()
.find(|m| m.name == name)
.with_context(|| format!("no model manifest named `{name}` in {}", dir.display()))?;
println!("fetching {} ({})...", manifest.name, manifest.license);
let resolved = manifest.fetch()?;
for (file, path) in &resolved.files {
println!(" {file} -> {}", path.display());
}
return Ok(());
}
println!("{:<6} {:<20} {:<14} {:<7} SOURCE", "TASK", "MODEL", "LICENSE", "CACHED");
for m in &manifests {
println!(
"{:<6} {:<20} {:<14} {:<7} {}",
m.task,
m.name,
m.license,
if m.is_cached() { "yes" } else { "no" },
m.hf_repo.as_deref().unwrap_or("-")
);
}
println!("\ncache root: {}", ffai_models::cache_dir().display());
}
Cmd::Asr {
input,
output,
engine,
language,
word_timestamps,
diarize,
max_speakers,
diarize_threshold,
vad,
no_vad,
vad_threshold,
vad_chunk_secs,
} => {
if let Some(0) = max_speakers {
anyhow::bail!("--max-speakers must be at least 1");
}
if !(0.0..=2.0).contains(&diarize_threshold) {
anyhow::bail!(
"--diarize-threshold is a cosine distance and must be in 0..=2, got \
{diarize_threshold}"
);
}
if !(0.0..=1.0).contains(&vad_threshold) {
anyhow::bail!("--vad-threshold must be in 0..=1, got {vad_threshold}");
}
if vad_chunk_secs <= 0.0 || vad_chunk_secs > 30.0 {
anyhow::bail!(
"--vad-chunk-secs must be in (0, 30]; Whisper's context is 30 s and a \
longer window cannot be represented (got {vad_chunk_secs})"
);
}
let _ = vad;
if no_vad && (word_timestamps || diarize) {
anyhow::bail!(
"--no-vad conflicts with --word-timestamps/--diarize, which need speech \
segmentation to work. Drop --no-vad, or drop the stage."
);
}
let vad_on = !no_vad;
let audio = ffai_media::load_audio(&input)?;
let opts = AsrOptions {
language,
word_timestamps,
diarize,
persist_speakers: false,
max_speakers,
diarize_threshold,
translate: false,
vad: vad_on,
vad_threshold,
vad_chunk_secs,
stream_offset_secs: 0.0,
};
let transcript = reg.asr(engine.as_deref())?.transcribe(&audio, &opts)?;
match output {
Some(path)
if matches!(
path.extension().and_then(|e| e.to_str()),
Some("srt" | "vtt" | "json")
) =>
{
let body = match path.extension().and_then(|e| e.to_str()) {
Some("srt") => transcript.to_srt(),
Some("vtt") => transcript.to_vtt(),
_ => transcript.to_json(),
};
std::fs::write(&path, body)?;
println!("wrote {}", path.display());
}
Some(path) => {
std::fs::write(&path, transcript.text())?;
println!("wrote {}", path.display());
}
None => println!("{}", transcript.text()),
}
if ffai_mercury::asr::profile::is_enabled() {
eprint!("{}", ffai_mercury::asr::profile::profile().report());
}
if let Some(audit) = ffai_mercury::asr::vocab_int8::audit_report() {
eprintln!("
{audit}");
}
}
Cmd::Tts {
text,
output,
engine,
voice,
speed,
noise_scale,
noise_w,
seed,
sentence_silence,
} => {
let opts = TtsOptions {
voice,
speed,
noise_scale,
noise_w,
seed,
sentence_silence_s: sentence_silence,
};
let audio = reg.tts(engine.as_deref())?.synthesize(&text, &opts)?;
ffai_media::save_wav(&output, &audio)?;
println!("wrote {}", output.display());
}
Cmd::Ocr { input, engine, language, live, fps, change_fraction, sample_every, output, watch } => {
let opts = OcrOptions { languages: language, ..Default::default() };
let eng = reg.ocr(engine.as_deref())?;
if live {
if fps <= 0.0 {
anyhow::bail!("--fps must be positive");
}
let list_frames = |seen: usize| -> Result<Vec<PathBuf>> {
let mut frames: Vec<PathBuf> = std::fs::read_dir(&input)
.with_context(|| format!("reading frame dir {}", input.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("png"))
.collect();
frames.sort();
Ok(frames.split_off(seen.min(frames.len())))
};
let cfg = ffai_carmenta::live::LiveConfig { change_fraction, sample_every, ..Default::default() };
let mut session = ffai_carmenta::live::LiveSession::new(eng.clone(), opts, cfg);
let mut n = 0usize;
let started = std::time::Instant::now();
let mut last_new = std::time::Instant::now();
loop {
let fresh = list_frames(n)?;
if fresh.is_empty() {
match watch {
Some(idle) if last_new.elapsed().as_secs_f64() < idle => {
std::thread::sleep(std::time::Duration::from_millis(100));
continue;
}
_ => break,
}
}
for frame in &fresh {
let img = ffai_media::load_image(frame)?;
let t = if watch.is_some() { started.elapsed().as_secs_f64() } else { n as f64 / fps };
session.push_frame(&img, t)?;
n += 1;
}
last_new = std::time::Instant::now();
if n > 0 && watch.is_none() && list_frames(n)?.is_empty() {
break;
}
}
if n == 0 {
anyhow::bail!("no .png frames in {}", input.display());
}
let end_t = if watch.is_some() { started.elapsed().as_secs_f64() } else { n as f64 / fps };
let (segments, stats) = session.finish(end_t);
eprintln!(
"{n} frames: {} OCR calls, {} change-gated, {} sampled out; \
p50 {:.0} ms / p95 {:.0} ms per call",
stats.ocr_calls,
stats.gated,
stats.sampled_out,
stats.percentile(0.50).unwrap_or(0.0) * 1000.0,
stats.percentile(0.95).unwrap_or(0.0) * 1000.0,
);
let body = match output.as_ref().and_then(|p| p.extension()).and_then(|e| e.to_str()) {
Some("vtt") => ffai_carmenta::live::to_vtt(&segments),
_ => ffai_carmenta::live::to_srt(&segments),
};
match output {
Some(path) => {
std::fs::write(&path, body)?;
println!("wrote {}", path.display());
}
None => print!("{body}"),
}
} else {
let image = ffai_media::load_image(&input)?;
let out = eng.recognize(&image, &opts)?;
println!("{}", out.text());
}
if ffai_carmenta::profile::is_enabled() {
eprint!("{}", ffai_carmenta::profile::profile().report());
}
}
Cmd::Detect { input, engine, conf, iou, max_det, classes, output } => {
let image = ffai_media::load_image(&input)?;
let eng = reg.detect(engine.as_deref())?;
let opts = DetectOptions {
confidence: conf,
max_detections: max_det,
iou,
classes,
};
let out = eng.detect(&image, &opts)?;
let names = eng.class_names();
let label = |id: u32| -> &str {
names.get(id as usize).map(String::as_str).unwrap_or("?")
};
let body = match output.as_ref().and_then(|p| p.extension()).and_then(|e| e.to_str()) {
Some("jsonl") => out
.detections
.iter()
.map(|d| {
format!(
"{{\"x0\":{:.2},\"y0\":{:.2},\"x1\":{:.2},\"y1\":{:.2},\
\"class\":{},\"name\":\"{}\",\"confidence\":{:.5}}}",
d.x0,
d.y0,
d.x1,
d.y1,
d.class_id,
label(d.class_id),
d.confidence
)
})
.collect::<Vec<_>>()
.join("\n"),
_ => out
.detections
.iter()
.map(|d| {
format!(
"{:<16} {:.3} [{:.1}, {:.1}, {:.1}, {:.1}]",
label(d.class_id),
d.confidence,
d.x0,
d.y0,
d.x1,
d.y1
)
})
.collect::<Vec<_>>()
.join("\n"),
};
match output {
Some(path) => {
std::fs::write(&path, format!("{body}\n"))?;
println!("wrote {} ({} detections)", path.display(), out.detections.len());
}
None => println!("{body}"),
}
}
Cmd::Caption { input, prompt, engine } => {
let image = ffai_media::load_image(&input)?;
let opts = VlmOptions { prompt, max_new_tokens: None };
let caption = reg.vlm(engine.as_deref())?.describe_image(&image, &opts)?;
println!("{caption}");
}
Cmd::Bench { task, corpus, refs, engine, only, baseline_only, runs, ledger } => {
let task = Task::from_str(&task).map_err(anyhow::Error::msg)?;
if !matches!(task, Task::Asr | Task::Ocr | Task::Tts | Task::Detect) {
anyhow::bail!(
"`ffai bench {task}` is not wired yet — asr, ocr, tts and detect are the \
live bench verticals; vlm follows its engine (see ROADMAP.md)"
);
}
let task_name = task.to_string();
let references: Vec<_> = if refs.exists() {
let file = ffai_bench::reference::ReferenceFile::load(&refs)?;
let mut selected: Vec<_> = file
.for_task(&task_name)
.filter(|r| only.is_empty() || only.contains(&r.name))
.cloned()
.collect();
for name in &only {
if !selected.iter().any(|r| &r.name == name) {
anyhow::bail!("--only {name}: no such reference in {}", refs.display());
}
}
if task == Task::Tts {
selected.extend(file.for_task("tts-judge").cloned());
}
selected
} else {
eprintln!(
"note: no references file at {} — running without world-standard baselines",
refs.display()
);
Vec::new()
};
let cfg = ffai_bench::runner::BenchConfig {
engine,
skip_engine: baseline_only,
corpus,
references,
runs,
ledger: ledger.clone(),
};
let record = match task {
Task::Asr => ffai_bench::runner::run_asr(®, &cfg)?,
Task::Ocr => ffai_bench::runner::run_ocr(®, &cfg)?,
Task::Tts => ffai_bench::tts::run_tts(®, &cfg)?,
Task::Detect => ffai_bench::runner::run_detect(®, &cfg)?,
_ => unreachable!("guarded above"),
};
print!("{}", ffai_bench::runner::render(&record));
println!("appended to {}", ledger.display());
}
}
Ok(())
}