#[cfg(not(feature = "mimalloc"))]
#[global_allocator]
static GLOBAL: rusty_alloc_api::RustyAlloc = rusty_alloc_api::RustyAlloc;
#[cfg(feature = "mimalloc")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[cfg(not(feature = "mimalloc"))]
fn spawn_page_trimmer() {
let ms = std::env::var("FFAI_TRIM_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
if ms == 0 {
return;
}
std::thread::spawn(move || {
loop {
std::thread::sleep(std::time::Duration::from_millis(ms));
rusty_alloc::alloc::collect(false);
}
});
}
#[cfg(feature = "mimalloc")]
fn spawn_page_trimmer() {}
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ffai_core::engine::{
AsrOptions, Decoding, DepthOptions, DetectEngine, 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, required_unless_present = "serve")]
input: Option<PathBuf>,
#[arg(long)]
live: bool,
#[arg(long)]
track: bool,
#[arg(long, default_value_t = 0.5)]
track_thresh: f32,
#[arg(long, default_value_t = 0.7)]
new_track_thresh: f32,
#[arg(long)]
serve: bool,
#[arg(long, default_value_t = ffai_diana::live::DEFAULT_CHANGE_FRACTION)]
change_fraction: f32,
#[arg(long, default_value_t = 1)]
sample_every: usize,
#[arg(long, default_value_t = ffai_diana::live::DEFAULT_PIXEL_DELTA)]
pixel_delta: u8,
#[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>,
},
Depth {
#[arg(short, long)]
input: PathBuf,
#[arg(long)]
engine: Option<String>,
#[arg(long)]
full_res: bool,
#[arg(short, long)]
output: Option<PathBuf>,
},
Caption {
#[arg(short, long)]
input: PathBuf,
#[arg(long)]
prompt: Option<String>,
#[arg(long)]
engine: Option<String>,
#[arg(long)]
max_new_tokens: Option<usize>,
#[arg(long)]
temperature: Option<f32>,
#[arg(long)]
top_p: Option<f32>,
#[arg(long)]
top_k: Option<usize>,
#[arg(long)]
seed: Option<u64>,
#[arg(long)]
repetition_penalty: Option<f32>,
#[arg(long = "stop")]
stop: Vec<String>,
#[arg(long, default_value_t = 1.0)]
fps: f64,
#[arg(long)]
window: Option<usize>,
#[arg(long)]
max_frames: Option<usize>,
#[arg(short, long)]
output: Option<PathBuf>,
},
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)]
engine_only: bool,
#[arg(long, default_value_t = 3)]
runs: usize,
#[arg(long, default_value = "bench/ledger.jsonl")]
ledger: PathBuf,
},
}
fn is_video_path(path: &std::path::Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("mp4" | "mov" | "m4v" | "mkv" | "webm" | "mka" | "avi" | "ts" | "m2ts" | "mts")
)
}
fn caption_video(
vlm: &dyn ffai_core::engine::VlmEngine,
input: &std::path::Path,
fps: f64,
max_frames: Option<usize>,
window: Option<usize>,
opts: &VlmOptions,
) -> Result<Vec<ffai_core::types::TimedSegment<String>>> {
let win = window.unwrap_or(8).max(1);
let mut buf: Vec<ffai_core::types::VideoFrame> = Vec::with_capacity(win);
let mut out: Vec<ffai_core::types::TimedSegment<String>> = Vec::new();
let mut seen = 0usize;
let mut last_ts = 0.0f64;
let flush = |buf: &mut Vec<ffai_core::types::VideoFrame>,
out: &mut Vec<ffai_core::types::TimedSegment<String>>|
-> Result<()> {
if buf.is_empty() {
return Ok(());
}
out.extend(vlm.describe_video(buf, opts)?);
buf.clear();
Ok(())
};
for frame in ffai_media::stream_frames(input, fps)? {
if max_frames.is_some_and(|m| seen >= m) {
break;
}
let frame = frame?;
last_ts = frame.timestamp;
buf.push(frame);
seen += 1;
if buf.len() == win {
flush(&mut buf, &mut out)?;
eprint!("\r {seen} frames, {} captions", out.len());
}
}
flush(&mut buf, &mut out)?;
if seen > 0 {
eprintln!(
"\r {seen} frames sampled at {fps} fps -> {} captions",
out.len()
);
} else {
return Err(anyhow::anyhow!(
"no frames decoded from {} — the container may hold no H.264 video track",
input.display()
));
}
for i in 0..out.len().saturating_sub(1) {
out[i].end = out[i + 1].start;
}
if fps > 0.0
&& let Some(last) = out.last_mut()
{
last.end = last_ts + 1.0 / fps;
}
Ok(out)
}
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 is_diana_shaped(cmd: &Cmd) -> bool {
match cmd {
Cmd::Detect { .. } | Cmd::Depth { .. } => true,
Cmd::Bench { task, .. } => matches!(task.as_str(), "detect" | "depth"),
_ => false,
}
}
fn match_candle_threads(cmd: &Cmd) {
if std::env::var_os("RAYON_NUM_THREADS").is_some() || !is_diana_shaped(cmd) {
return;
}
let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
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<()> {
let cli = Cli::parse();
match_candle_threads(&cli.cmd);
spawn_page_trimmer();
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 = match (engine.as_deref(), live) {
(Some(name), _) => reg.ocr(Some(name))?,
(None, true) => reg.ocr(None)?,
(None, false) => reg.ocr(Some(ffai_carmenta::DOC_DEFAULT))?,
};
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,
live,
serve,
track,
track_thresh,
new_track_thresh,
change_fraction,
sample_every,
pixel_delta,
} => {
let eng = reg.detect(engine.as_deref())?;
let opts = DetectOptions {
confidence: conf,
max_detections: max_det,
iou,
classes,
};
if serve {
return serve_stdin(eng, opts, live, change_fraction, sample_every, pixel_delta);
}
let input = input.context("--input is required without --serve")?;
let cfg = ffai_diana::track::TrackerConfig {
track_thresh,
new_track_thresh,
..Default::default()
};
if is_video(&input) {
return detect_video(eng, opts, &input, output.as_deref(), track, cfg);
}
if track && input.is_dir() {
return detect_dir_tracked(eng, opts, &input, output.as_deref(), cfg);
}
if live {
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| {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("png") | Some("jpg") | Some("jpeg")
)
})
.collect();
frames.sort();
if frames.is_empty() {
anyhow::bail!("no .png/.jpg frames in {}", input.display());
}
let cfg = ffai_diana::live::LiveConfig {
change_fraction,
sample_every,
pixel_delta,
..Default::default()
};
let mut session = ffai_diana::live::LiveSession::new(eng.clone(), cfg, opts);
let names = eng.class_names().to_vec();
let started = std::time::Instant::now();
let mut lines = Vec::new();
for f in &frames {
let image = ffai_media::load_image(f)?;
let out = session.process(&image)?;
let stem = f.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
for d in &out.detections {
lines.push(format!(
"{stem} {} {:.3} {:.0} {:.0} {:.0} {:.0}",
names.get(d.class_id as usize).map_or("?", String::as_str),
d.confidence,
d.x0,
d.y0,
d.x1,
d.y1
));
}
}
let wall = started.elapsed().as_secs_f64();
let st = session.stats();
println!(
"{} frames in {:.2}s — {} ran the model, {} gated, {} sampled out, {} forced",
st.frames, wall, st.processed, st.gated, st.sampled_out, st.forced
);
println!(
"skip rate {:.1}% ({:.1} fps against {:.1} fps if every frame ran)",
st.skip_rate() * 100.0,
st.frames as f64 / wall,
st.processed as f64 / wall
);
if let Some(path) = output {
std::fs::write(
&path,
format!(
"{}
",
lines.join(
"
"
)
),
)?;
println!("wrote {} ({} detections)", path.display(), lines.len());
}
return Ok(());
}
let image = ffai_media::load_image(&input)?;
let out = eng.detect(&image, &opts)?;
let names = eng.class_names();
let label = |id: u32| -> &str { names.get(id as usize).map_or("?", String::as_str) };
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::Depth {
input,
engine,
full_res,
output,
} => {
let image = ffai_media::load_image(&input)?;
let eng = reg.depth(engine.as_deref())?;
let out = eng.depth(
&image,
&DepthOptions {
full_resolution: full_res,
},
)?;
let (lo, hi) = out.range().unwrap_or((0.0, 0.0));
let finite = out.depth.iter().filter(|v| v.is_finite()).count();
println!(
"{} x {} depth {:.2}-{:.2} m ({} of {} pixels covered)",
out.width,
out.height,
lo,
hi,
finite,
out.depth.len()
);
match output
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
{
Some("png") => {
let span = (hi - lo).max(1e-6);
let px: Vec<u16> = out
.depth
.iter()
.map(|&d| {
if d.is_finite() {
(((hi - d) / span).clamp(0.0, 1.0) * 65535.0) as u16
} else {
0
}
})
.collect();
let path = output.as_ref().unwrap();
ffai_media::save_gray16_png(path, &px, out.width, out.height)?;
println!(
"wrote {} (16-bit grayscale, normalised {lo:.2}-{hi:.2} m)",
path.display()
);
}
Some("bin") => {
let path = output.as_ref().unwrap();
let mut bytes = Vec::with_capacity(out.depth.len() * 4);
for v in &out.depth {
bytes.extend_from_slice(&v.to_le_bytes());
}
std::fs::write(path, &bytes)?;
println!(
"wrote {} (raw f32 metres, {} x {}, row-major)",
path.display(),
out.width,
out.height
);
}
Some(other) => anyhow::bail!(
"unknown depth output extension `.{other}` — use .png (visualisation) or .bin (raw f32 metres)"
),
None => {}
}
}
Cmd::Caption {
input,
prompt,
engine,
max_new_tokens,
temperature,
top_p,
top_k,
seed,
repetition_penalty,
stop,
fps,
window,
max_frames,
output,
} => {
let decoding = match seed {
Some(seed) => Decoding::Sampled {
temperature: temperature.unwrap_or(1.0),
top_p,
top_k,
seed,
},
None => {
if temperature.is_some() || top_p.is_some() || top_k.is_some() {
anyhow::bail!(
"--temperature/--top-p/--top-k select SAMPLED decoding, which needs \
--seed to be reproducible. Add --seed <n>, or drop these flags for \
greedy decoding (the default, deterministic without a seed)."
);
}
Decoding::Greedy
}
};
let opts = VlmOptions {
prompt,
system_prompt: None,
decoding,
max_new_tokens,
stop,
repetition_penalty,
frames_per_window: window,
};
let vlm = reg.vlm(engine.as_deref())?;
if is_video_path(&input) {
let segments = caption_video(&*vlm, &input, fps, max_frames, window, &opts)?;
let track = ffai_core::types::Transcript {
language: None,
segments,
words: None,
speakers: None,
};
let rendered = match output
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
{
Some("srt") => track.to_srt(),
Some("vtt") => track.to_vtt(),
Some("json") => track.to_json(),
_ => track
.segments
.iter()
.map(|s| format!("[{:>8.2} - {:>8.2}] {}", s.start, s.end, s.value.trim()))
.collect::<Vec<_>>()
.join("\n"),
};
match output {
Some(path) => {
std::fs::write(&path, &rendered)?;
println!(
"wrote {} ({} captions)",
path.display(),
track.segments.len()
);
}
None => println!("{rendered}"),
}
} else {
let image = ffai_media::load_image(&input)?;
let caption = vlm.describe_image(&image, &opts)?;
match output {
Some(path) => {
std::fs::write(&path, &caption)?;
println!("wrote {}", path.display());
}
None => println!("{caption}"),
}
}
}
Cmd::Bench {
task,
corpus,
refs,
engine,
only,
baseline_only,
engine_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 | Task::Vlm
) {
anyhow::bail!(
"`ffai bench {task}` is not wired yet — asr, ocr, tts, detect and vlm are \
the live bench verticals (see ROADMAP.md)"
);
}
let task_name = task.to_string();
let mut scorers = Vec::new();
let references: Vec<_> = if refs.exists() {
let file = ffai_bench::reference::ReferenceFile::load(&refs)?;
scorers.clone_from(&file.scorers);
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,
skip_references: engine_only,
corpus,
references,
scorers,
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)?,
Task::Vlm => ffai_bench::vlm::run_vlm(®, &cfg)?,
Task::Depth => unreachable!("guarded above"),
};
print!("{}", ffai_bench::runner::render(&record));
println!("appended to {}", ledger.display());
}
}
Ok(())
}
fn is_video(p: &std::path::Path) -> bool {
matches!(
p.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("mp4" | "mov" | "m4v" | "mkv" | "webm" | "mka" | "avi" | "ts" | "m2ts" | "mts")
)
}
fn detect_video(
eng: std::sync::Arc<dyn DetectEngine>,
opts: DetectOptions,
path: &std::path::Path,
output: Option<&std::path::Path>,
track: bool,
cfg: ffai_diana::track::TrackerConfig,
) -> Result<()> {
use std::io::Write;
let names = eng.class_names().to_vec();
let mut tracker = track.then(|| ffai_diana::track::ByteTrack::new(cfg));
let stream = ffai_media::stream_frames(path, 0.0)?;
let total = stream.frame_count_hint();
let mut lines: Vec<String> = Vec::new();
let mut n_frames = 0usize;
let mut infer_total = 0.0f64;
let mut shape = (0usize, 0usize);
for (i, frame) in stream.enumerate() {
let frame = frame?;
let t = std::time::Instant::now();
let found = eng.detect(&frame.image, &opts)?;
let ms = t.elapsed().as_secs_f64() * 1000.0;
infer_total += ms;
n_frames += 1;
let mut tally: std::collections::BTreeMap<u32, usize> = std::collections::BTreeMap::new();
for d in &found.detections {
*tally.entry(d.class_id).or_insert(0) += 1;
}
let summary = if tally.is_empty() {
"(no detections)".to_string()
} else {
tally
.iter()
.map(|(c, n)| {
let base = names.get(*c as usize).map_or("?", String::as_str);
if *n == 1 {
format!("1 {base}")
} else {
format!("{n} {base}s")
}
})
.collect::<Vec<_>>()
.join(", ")
};
let (lh, lw) = ffai_diana::image::letterbox_shape(
frame.image.width as usize,
frame.image.height as usize,
640,
ffai_diana::image::Geometry::Rect,
);
shape = (lh, lw);
match total {
Some(t) => println!(
"video 1/1 (frame {}/{}) {}: {}x{} {}, {:.1}ms",
i + 1,
t,
path.display(),
lh,
lw,
summary,
ms
),
None => println!(
"video 1/1 (frame {}) {}: {}x{} {}, {:.1}ms",
i + 1,
path.display(),
lh,
lw,
summary,
ms
),
}
if let Some(tk) = tracker.as_mut() {
let bx: Vec<[f32; 4]> = found
.detections
.iter()
.map(|d| [d.x0, d.y0, d.x1, d.y1])
.collect();
let sc: Vec<f32> = found.detections.iter().map(|d| d.confidence).collect();
let cl: Vec<u32> = found.detections.iter().map(|d| d.class_id).collect();
for t in tk.update(&bx, &sc, &cl) {
let bb = t.xyxy();
lines.push(format!(
"{},{},{:.1},{:.1},{:.1},{:.1},{:.3},-1,-1,-1",
i + 1,
t.id,
bb[0],
bb[1],
bb[2] - bb[0],
bb[3] - bb[1],
t.score
));
}
} else if output.is_some() {
for d in &found.detections {
lines.push(format!(
"{}\t{}\t{:.3}\t{:.0}\t{:.0}\t{:.0}\t{:.0}",
i,
names.get(d.class_id as usize).map_or("?", String::as_str),
d.confidence,
d.x0,
d.y0,
d.x1,
d.y1
));
}
}
}
if n_frames == 0 {
anyhow::bail!("{}: no frames decoded", path.display());
}
println!(
"Speed: {:.1}ms inference per image at shape (1, 3, {}, {})",
infer_total / n_frames as f64,
shape.0,
shape.1
);
if let Some(p) = output {
let mut f = std::io::BufWriter::new(std::fs::File::create(p)?);
for l in &lines {
writeln!(f, "{l}")?;
}
println!("wrote {} ({} detections)", p.display(), lines.len());
}
Ok(())
}
fn detect_dir_tracked(
eng: std::sync::Arc<dyn DetectEngine>,
opts: DetectOptions,
dir: &std::path::Path,
output: Option<&std::path::Path>,
cfg: ffai_diana::track::TrackerConfig,
) -> Result<()> {
use std::io::Write;
let mut frames: Vec<PathBuf> = std::fs::read_dir(dir)
.with_context(|| format!("reading frame dir {}", dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("png" | "jpg" | "jpeg")
)
})
.collect();
frames.sort();
if frames.is_empty() {
anyhow::bail!("no .png/.jpg frames in {}", dir.display());
}
let mut tk = ffai_diana::track::ByteTrack::new(cfg);
let mut lines = Vec::new();
for (i, f) in frames.iter().enumerate() {
let image = ffai_media::load_image(f)?;
let found = eng.detect(&image, &opts)?;
let bx: Vec<[f32; 4]> = found
.detections
.iter()
.map(|d| [d.x0, d.y0, d.x1, d.y1])
.collect();
let sc: Vec<f32> = found.detections.iter().map(|d| d.confidence).collect();
let cl: Vec<u32> = found.detections.iter().map(|d| d.class_id).collect();
for t in tk.update(&bx, &sc, &cl) {
let bb = t.xyxy();
lines.push(format!(
"{},{},{:.1},{:.1},{:.1},{:.1},{:.3},-1,-1,-1",
i + 1,
t.id,
bb[0],
bb[1],
bb[2] - bb[0],
bb[3] - bb[1],
t.score
));
}
}
match output {
Some(p) => {
let mut w = std::io::BufWriter::new(std::fs::File::create(p)?);
for l in &lines {
writeln!(w, "{l}")?;
}
println!(
"{} frames, {} tracked boxes -> {}",
frames.len(),
lines.len(),
p.display()
);
}
None => {
for l in &lines {
println!("{l}");
}
}
}
Ok(())
}
fn serve_stdin(
eng: std::sync::Arc<dyn DetectEngine>,
opts: DetectOptions,
live: bool,
change_fraction: f32,
sample_every: usize,
pixel_delta: u8,
) -> Result<()> {
use std::io::{BufRead, Write};
let names = eng.class_names().to_vec();
let mut session = live.then(|| {
let cfg = ffai_diana::live::LiveConfig {
change_fraction,
sample_every,
pixel_delta,
..Default::default()
};
ffai_diana::live::LiveSession::new(eng.clone(), cfg, opts.clone())
});
let mut out = std::io::stdout().lock();
writeln!(out, "{{\"ready\":true,\"live\":{live}}}")?;
out.flush()?;
let stdin = std::io::stdin();
let mut n_frames: u64 = 0;
for line in stdin.lock().lines() {
let line = line?;
let path = line.trim();
if path.is_empty() {
continue;
}
n_frames += 1;
let t_dec = std::time::Instant::now();
let image = match ffai_media::load_image(std::path::Path::new(path)) {
Ok(i) => i,
Err(e) => {
writeln!(out, "{{\"error\":\"{}\"}}", e.to_string().replace('"', "'"))?;
out.flush()?;
continue;
}
};
let decode_ms = t_dec.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
let (found, gated) = match session.as_mut() {
Some(s) => {
let before = s.stats().processed;
let r = s.process(&image)?;
let ran = s.stats().processed != before;
(r, !ran)
}
None => (eng.detect(&image, &opts)?, false),
};
let ms = t.elapsed().as_secs_f64() * 1e3;
let dets = found
.detections
.iter()
.map(|d| {
format!(
"{{\"x0\":{:.1},\"y0\":{:.1},\"x1\":{:.1},\"y1\":{:.1},\
\"class\":{},\"name\":\"{}\",\"conf\":{:.3}}}",
d.x0,
d.y0,
d.x1,
d.y1,
d.class_id,
names.get(d.class_id as usize).map_or("?", String::as_str),
d.confidence
)
})
.collect::<Vec<_>>()
.join(",");
writeln!(
out,
"{{\"ms\":{:.2},\"detect_ms\":{ms:.2},\"decode_ms\":{decode_ms:.2},\
\"gated\":{gated},\"n\":{},\"detections\":[{dets}]}}",
ms + decode_ms,
found.detections.len()
)?;
out.flush()?;
}
if ffai_diana::profile::is_enabled() {
eprintln!("{}", ffai_diana::profile::profile().report());
}
if ffai_diana::profile::roofline_enabled() {
eprintln!("{}", ffai_diana::profile::roofline_report(n_frames));
eprintln!("{}", ffai_diana::profile::sliceop_report(n_frames));
eprintln!("{}", ffai_diana::profile::denorm_report(n_frames));
eprintln!("{}", ffai_diana::profile::plumb_report(n_frames));
}
Ok(())
}