#![allow(clippy::doc_markdown)]
use std::path::PathBuf;
use anyhow::Context as _;
use bpaf::Bpaf;
use ffcine::triage::{self, Index, SnapParams, timecode};
const DEFAULT_INDEX: &str = "triage/index.json";
#[cfg(feature = "gpu")]
#[derive(Debug, Clone, Bpaf)]
#[allow(clippy::struct_excessive_bools)] pub struct TriageArgs {
#[bpaf(short('o'), long("out"), argument("DIR"))]
out: Option<PathBuf>,
#[bpaf(argument("FILE"))]
list: Option<PathBuf>,
no_catalog: bool,
#[bpaf(short('j'), long("jobs"), argument("N"), fallback(4))]
jobs: usize,
no_transcribe: bool,
#[bpaf(argument("NAME"), fallback("large-v3-turbo".to_owned()))]
model: String,
#[bpaf(argument("PATH"))]
whisper_python: Option<PathBuf>,
#[bpaf(argument("LANG"))]
language: Option<String>,
#[bpaf(argument("WORDS"))]
hotwords: Option<String>,
whisper_cpu: bool,
cpu_decode: bool,
#[bpaf(argument("MIN"), fallback(20.0))]
runaway_minutes: f64,
#[bpaf(argument("SEC"), fallback(1.0))]
min_speech: f64,
#[bpaf(argument("URL"))]
immich: Option<String>,
#[bpaf(argument("FILE"))]
immich_key_file: Option<PathBuf>,
#[bpaf(argument::<String>("ZONE"), parse(|s| parse_zone(&s)), fallback(0))]
camera_tz: i32,
force: bool,
#[bpaf(argument("TEXT"))]
title: Option<String>,
#[bpaf(positional("DIR"))]
dir: PathBuf,
}
#[cfg(feature = "gpu")]
impl TriageArgs {
pub fn run(self) -> anyhow::Result<()> {
let out = self.out.clone().unwrap_or_else(|| self.dir.join("triage"));
let title = self.title.clone().unwrap_or_else(|| {
let d = std::fs::canonicalize(&self.dir).unwrap_or_else(|_| self.dir.clone());
d.file_name()
.map_or_else(|| "Clips".into(), |s| s.to_string_lossy().into_owned())
});
let whisper = (!self.no_transcribe).then(|| {
let mut w = triage::transcribe::WhisperParams {
model: self.model.clone(),
language: self.language.clone().unwrap_or_default(),
hotwords: self.hotwords.clone().unwrap_or_default(),
..Default::default()
};
if let Some(p) = &self.whisper_python {
w.python.clone_from(p);
}
if self.whisper_cpu {
w.device = "cpu".into();
w.compute = "int8".into();
}
w
});
let immich = match &self.immich {
Some(url) => {
let file = self
.immich_key_file
.clone()
.or_else(|| {
std::env::var_os("FFCINE_IMMICH_KEY_FILE")
.or_else(|| std::env::var_os("FFCINE_IMMICH_API_KEY_FILE"))
.map(PathBuf::from)
})
.context("--immich needs --immich-key-file")?;
let key = std::fs::read_to_string(&file)
.with_context(|| format!("reading {}", file.display()))?
.trim()
.to_owned();
Some(triage::location::ImmichParams {
url: url.clone(),
key,
tz_minutes: self.camera_tz,
slack: 15.0 * 60.0,
})
}
None => None,
};
let cores = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
let jobs = self.jobs.max(1);
let opts = triage::Options {
dir: self.dir,
list: self.list,
no_catalog: self.no_catalog,
out,
jobs,
scan: triage::decode::ScanParams {
gpu: !self.cpu_decode,
threads: (cores / jobs).max(1),
..Default::default()
},
vad: triage::vad::VadParams::default(),
flags: triage::flags::FlagParams {
runaway: self.runaway_minutes * 60.0,
..Default::default()
},
immich,
whisper,
min_speech: self.min_speech,
force: self.force,
title,
};
let s = triage::run(&opts)?;
eprintln!(
"{} clips ({} analysed, {} transcribed), {} failed\n{}\n{}",
s.clips,
s.analysed,
s.transcribed,
s.failed.len(),
s.index.display(),
s.page.display()
);
for (id, e) in &s.failed {
eprintln!(" {id}: {e}");
}
Ok(())
}
}
fn load_index(path: Option<PathBuf>) -> anyhow::Result<Index> {
let path = path
.or_else(|| std::env::var_os("FFCINE_TRIAGE_INDEX").map(PathBuf::from))
.unwrap_or_else(|| DEFAULT_INDEX.into());
Index::load(&path).with_context(|| format!("reading {}", path.display()))
}
#[derive(Debug, Clone, Bpaf)]
pub struct SearchArgs {
#[bpaf(argument("FILE"))]
index: Option<PathBuf>,
paths: bool,
#[bpaf(positional("QUERY"), some("a query"))]
query: Vec<String>,
}
impl SearchArgs {
pub fn run(self) -> anyhow::Result<()> {
let ix = load_index(self.index)?;
let hits = triage::search(&ix, &self.query.join(" "));
for h in &hits {
let name = if self.paths {
h.clip.source.display().to_string()
} else {
h.clip.id.clone()
};
println!("{name} {} {}", timecode(h.start), h.context);
}
eprintln!("{} hits", hits.len());
Ok(())
}
}
#[cfg_attr(not(feature = "gpu"), allow(dead_code))]
fn parse_zone(s: &str) -> Result<i32, String> {
let bad = || format!("bad zone {s:?} (want +HH:MM)");
let (sign, rest) = match s.as_bytes().first() {
Some(b'+') => (1, &s[1..]),
Some(b'-') => (-1, &s[1..]),
_ => (1, s),
};
let digits: String = rest.chars().filter(char::is_ascii_digit).collect();
let (h, m) = match digits.len() {
1 | 2 => (digits.as_str(), "0"),
4 => digits.split_at(2),
_ => return Err(bad()),
};
let h: i32 = h.parse().map_err(|_| bad())?;
let m: i32 = m.parse().map_err(|_| bad())?;
if h > 14 || m > 59 {
return Err(bad());
}
Ok(sign * (h * 60 + m))
}
fn parse_time(s: &str) -> Result<f64, String> {
let mut t = 0.0;
for part in s.split(':') {
let v: f64 = part.parse().map_err(|_| format!("bad time {s:?}"))?;
t = t * 60.0 + v;
}
Ok(t)
}
#[derive(Debug, Clone, Bpaf)]
pub struct SnapArgs {
#[bpaf(argument("FILE"))]
index: Option<PathBuf>,
#[bpaf(argument("SEC"), fallback(0.12))]
pad: f64,
keep: bool,
#[bpaf(positional("CLIP"))]
clip: String,
#[bpaf(positional::<String>("IN"), parse(|s| parse_time(&s)))]
cut_in: f64,
#[bpaf(positional::<String>("OUT"), parse(|s| parse_time(&s)))]
cut_out: f64,
}
impl SnapArgs {
pub fn run(self) -> anyhow::Result<()> {
let ix = load_index(self.index)?;
let clip = ix
.clip(&self.clip)
.with_context(|| format!("no clip {} in the index", self.clip))?;
let p = SnapParams {
pad: self.pad,
prefer: if self.keep {
triage::Prefer::Keep
} else {
triage::Prefer::Nearest
},
};
let (i, o) = triage::snap(&triage::snap::units(clip), self.cut_in, self.cut_out, p);
println!("{i:.3} {o:.3} ({} - {})", timecode(i), timecode(o));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{parse_time, parse_zone};
#[test]
fn zones() {
assert_eq!(parse_zone("+09:00"), Ok(540));
assert_eq!(parse_zone("-0330"), Ok(-210));
assert_eq!(parse_zone("8"), Ok(480));
assert!(parse_zone("+9:0").is_err());
}
#[test]
fn times() {
assert_eq!(parse_time("12.5"), Ok(12.5));
assert_eq!(parse_time("1:02.5"), Ok(62.5));
assert_eq!(parse_time("1:00:00"), Ok(3600.0));
assert!(parse_time("x").is_err());
}
}