#[cfg(feature = "audio")]
mod audio;
#[cfg(feature = "gpu")]
mod gpu;
mod publish;
mod triage;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use anyhow::Context as _;
use bpaf::Bpaf;
use ffcine::ingest::{
self, CatalogOptions, ClockConfig, Event, Histogram, IngestOptions, Zone, catalog,
};
#[derive(Debug, Clone, Bpaf)]
#[bpaf(options, version)]
#[allow(clippy::large_enum_variant, clippy::doc_markdown)] enum Cmd {
#[cfg(feature = "gpu")]
Gpu(#[bpaf(external(gpu::cmd))] gpu::Cmd),
#[bpaf(command)]
Publish {
#[bpaf(argument("ALBUM"))]
immich: String,
#[bpaf(argument("FILE"))]
config: Option<PathBuf>,
#[bpaf(positional("FILE"), some("a file to upload"))]
files: Vec<PathBuf>,
},
#[bpaf(command)]
Ingest(#[bpaf(external(ingest_args))] IngestArgs),
#[bpaf(command)]
Catalog(#[bpaf(external(catalog_args))] CatalogArgs),
#[bpaf(command)]
Doctor,
#[bpaf(command)]
Search(#[bpaf(external(triage::search_args))] triage::SearchArgs),
#[bpaf(command)]
Snap(#[bpaf(external(triage::snap_args))] triage::SnapArgs),
#[cfg(feature = "audio")]
#[bpaf(command)]
Sync(#[bpaf(external(audio::sync_args))] audio::SyncArgs),
#[cfg(feature = "audio")]
#[bpaf(command)]
Audio(#[bpaf(external(audio::audio_args))] audio::AudioArgs),
#[cfg(feature = "audio")]
#[bpaf(command)]
Beats(#[bpaf(external(audio::beats_args))] audio::BeatsArgs),
}
#[derive(Debug, Clone, Bpaf)]
#[allow(clippy::doc_markdown)]
struct ClockArgs {
#[bpaf(argument("FILE"))]
clocks: Option<PathBuf>,
#[bpaf(argument("CAM=SHIFT"), many)]
clock: Vec<String>,
#[bpaf(argument("ZONE"))]
zone: Option<String>,
}
impl ClockArgs {
fn config(&self, root: &Path) -> anyhow::Result<ClockConfig> {
let default = root.join("clocks.yaml");
let mut cfg = match &self.clocks {
Some(p) => ClockConfig::from_file(p)?,
None if default.exists() => ClockConfig::from_file(&default)?,
None => ClockConfig::default(),
};
for c in &self.clock {
cfg.set(c)?;
}
if let Some(z) = &self.zone {
cfg.zone = Some(z.parse::<Zone>()?);
}
Ok(cfg)
}
}
#[derive(Debug, Clone, Bpaf)]
#[allow(clippy::struct_excessive_bools, clippy::doc_markdown)]
struct IngestArgs {
#[bpaf(argument("DEST"))]
to: PathBuf,
#[bpaf(argument("DIR"))]
backup: Option<PathBuf>,
#[bpaf(argument("ID"))]
camera: Option<String>,
#[bpaf(external(clock_args))]
clocks: ClockArgs,
dry_run: bool,
verify_existing: bool,
no_catalog: bool,
#[bpaf(positional("CARD_DIR"))]
card: PathBuf,
}
#[derive(Debug, Clone, Bpaf)]
struct CatalogArgs {
#[bpaf(external(clock_args))]
clocks: ClockArgs,
#[bpaf(argument("WHEN"), fallback(Histogram::Auto))]
histogram: Histogram,
full: bool,
#[bpaf(positional("DIR"))]
dir: PathBuf,
}
fn gib(b: u64) -> String {
#[allow(clippy::cast_precision_loss)]
let g = b as f64 / f64::from(1u32 << 30);
format!("{g:.2} GiB")
}
fn print_catalog(path: &Path, c: &catalog::Catalog) {
println!("{} clips -> {}", c.clips.len(), path.display());
for (cam, s) in &c.cameras {
let fps: Vec<String> = s.fps.iter().map(|(r, n)| format!("{r} x{n}")).collect();
let prof: Vec<String> = s
.profiles
.iter()
.map(|(p, n)| format!("{p} x{n}"))
.collect();
println!(
" {cam}: {} clips, {:.1} min, {}, days {}{}{}",
s.clips,
s.seconds / 60.0,
gib(s.bytes),
s.days.iter().cloned().collect::<Vec<_>>().join(" "),
if fps.is_empty() {
String::new()
} else {
format!(", fps {}", fps.join(" "))
},
if prof.is_empty() {
String::new()
} else {
format!(", {}", prof.join(" "))
},
);
}
for w in &c.warnings {
println!("warning: {w}");
}
for i in &c.clock_issues {
match i.suggest_shift {
Some(s) => println!("clock: {}: {} (set {}: {s})", i.camera, i.message, i.camera),
None => println!("clock: {}: {}", i.camera, i.message),
}
}
if !c.overlaps.is_empty() {
println!(
"{} cross-camera overlaps (audio sync candidates)",
c.overlaps.len()
);
}
}
fn run_catalog(
dir: &Path,
clocks: ClockConfig,
histogram: Histogram,
full: bool,
) -> anyhow::Result<()> {
let opts = CatalogOptions {
clocks,
histogram,
full,
};
let c = catalog::build(dir, &opts, ingest::now_utc())
.with_context(|| format!("cataloguing {}", dir.display()))?;
let path = catalog::write(dir, &c)?;
print_catalog(&path, &c);
Ok(())
}
fn run_ingest(a: IngestArgs) -> anyhow::Result<()> {
let clocks = a.clocks.config(&a.to)?;
let opts = IngestOptions {
card: a.card,
dest: a.to.clone(),
backup: a.backup,
clocks: clocks.clone(),
camera: a.camera,
dry_run: a.dry_run,
verify_existing: a.verify_existing,
};
let (n, bytes) = (AtomicUsize::new(0), AtomicU64::new(0));
let on_event = |e: Event<'_>| match e {
Event::Copied { rel, bytes: b } => {
let k = n.fetch_add(1, Ordering::Relaxed) + 1;
let total = bytes.fetch_add(b, Ordering::Relaxed) + b;
eprintln!("copied {rel} ({k} files, {})", gib(total));
}
Event::Skipped { rel } => eprintln!("have {rel}"),
Event::BackedUp { rel } => eprintln!("backup {rel}"),
Event::Failed { path, error } => eprintln!("FAILED {}: {error}", path.display()),
};
let r = ingest::ingest(&opts, &on_event)?;
for (src, rel) in &r.planned {
println!("{} -> {rel}", src.display());
}
println!(
"{} copied ({}), {} already present, {} backed up, {} ignored",
r.copied,
gib(r.bytes),
r.skipped,
r.backed_up,
r.ignored.len()
);
for p in &r.ignored {
eprintln!("ignored {}", p.display());
}
if !r.ok() {
for (p, e) in &r.failed {
eprintln!("FAILED {}: {e}", p.display());
}
anyhow::bail!(
"{} file(s) failed; the card is untouched, re-run to retry",
r.failed.len()
);
}
if !a.dry_run && !a.no_catalog {
run_catalog(&a.to, clocks, Histogram::Auto, false)?;
}
Ok(())
}
fn ffmpeg_out(args: &[&str]) -> Option<String> {
std::process::Command::new("ffmpeg")
.args(args)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
}
fn ffmpeg_cli_checks() -> Vec<(String, Result<String, String>)> {
let Some(version) = ffmpeg_out(&["-hide_banner", "-version"]) else {
return vec![("ffmpeg cli".into(), Err("not on PATH".into()))];
};
let mut out = vec![(
"ffmpeg cli".into(),
Ok(version.lines().next().unwrap_or_default().to_owned()),
)];
let list = |args: &[&str]| ffmpeg_out(args).unwrap_or_default();
let has = |text: &str, name: &str| {
text.lines()
.any(|l| l.split_whitespace().any(|w| w == name))
};
let filters = list(&["-hide_banner", "-filters"]);
for f in ["libplacebo", "nlmeans_vulkan"] {
out.push((
format!("ffmpeg cli filter {f}"),
if has(&filters, f) {
Ok("available".into())
} else {
Err("missing".into())
},
));
}
let hw = list(&["-hide_banner", "-hwaccels"]);
for h in ["cuda", "vulkan"] {
out.push((
format!("ffmpeg cli hwaccel {h}"),
if has(&hw, h) {
Ok("available".into())
} else {
Err("missing".into())
},
));
}
let enc = list(&["-hide_banner", "-encoders"]);
out.push((
"ffmpeg cli encoder hevc_nvenc".into(),
if has(&enc, "hevc_nvenc") {
Ok("available".into())
} else {
Err("missing".into())
},
));
out
}
fn doctor() -> anyhow::Result<()> {
let mut failed = Vec::new();
for (name, r) in ffmpeg_cli_checks() {
match r {
Ok(d) => println!("ok {name}: {d}"),
Err(e) if cfg!(feature = "gpu") => {
println!("FAIL {name}: {e}");
failed.push(name);
}
Err(e) => println!("info {name}: {e}"),
}
}
gpu_doctor(&mut failed);
if !failed.is_empty() {
anyhow::bail!(
"{} check(s) failed: {}; renders would not run (fully) on the GPU",
failed.len(),
failed.join(", ")
);
}
Ok(())
}
#[cfg(feature = "gpu")]
fn gpu_doctor(failed: &mut Vec<String>) {
let mut checks = ffcine::backend::doctor::checks();
checks.push(ffcine::backend::doctor::Check {
name: "nvof",
result: ffcine::stabilize::nvof::max_api_version()
.map(|(a, b)| format!("optical flow API {a}.{b}"))
.map_err(|e| e.to_string()),
needed_for: "stabilize detect",
});
for c in &checks {
match &c.result {
Ok(d) => println!("ok {}: {d}", c.name),
Err(e) => {
println!("FAIL {}: {e} (needed for {})", c.name, c.needed_for);
failed.push(c.name.to_owned());
}
}
}
}
#[cfg(not(feature = "gpu"))]
fn gpu_doctor(_: &mut Vec<String>) {
println!(
"info gpu: not built (ingest/catalog build); grade and stabilize need the gpu feature"
);
}
fn main() -> anyhow::Result<()> {
match cmd().run() {
#[cfg(feature = "gpu")]
Cmd::Gpu(c) => gpu::run(c),
Cmd::Ingest(a) => run_ingest(a),
Cmd::Catalog(a) => {
let clocks = a.clocks.config(&a.dir)?;
run_catalog(&a.dir, clocks, a.histogram, a.full)
}
Cmd::Doctor => doctor(),
Cmd::Publish {
immich,
config,
files,
} => publish::Immich::from_config(config.as_deref())?
.publish(&immich, &files)
.map(drop),
Cmd::Search(a) => a.run(),
Cmd::Snap(a) => a.run(),
#[cfg(feature = "audio")]
Cmd::Sync(a) => a.run(),
#[cfg(feature = "audio")]
Cmd::Audio(a) => a.run(),
#[cfg(feature = "audio")]
Cmd::Beats(a) => a.run(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_invariants() {
cmd().check_invariants(false);
}
#[test]
fn ingest_args() {
let Cmd::Ingest(a) = cmd()
.run_inner(&[
"ingest",
"--to",
"/proj",
"--backup",
"/backup/x",
"--clock",
"r10=-2m",
"--clock",
"pocket=+0h",
"/Volumes/CARD",
])
.unwrap()
else {
panic!("expected ingest");
};
assert_eq!(a.card, PathBuf::from("/Volumes/CARD"));
assert_eq!(a.clocks.clock.len(), 2);
let cfg = a.clocks.config(Path::new("/nonexistent")).unwrap();
assert!((cfg.cameras["r10"].shift.0 + 120.0).abs() < 1e-9);
}
}