use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use anyhow::Context as _;
use bpaf::{Bpaf, Parser as _};
use ffcine::camera::{self, ProfileChoice};
use ffcine::reframe::{Profile, Reframe, Shot};
use ffcine::stabilize::{self, DetectParams, FlowQuality, SourceRange, StabilizeParams, Zoom};
use ffcine::{
Backend as _, Codec, ColorMapTuning, ColorOverride, Crop, Deband, Decode, Denoise, Device,
Dither, DitherMethod, EncoderSettings, GamutMapping, Grade, Grain, Lut, LutStage, LutType,
Pipeline, PixelFormat, RateControl, Rational, Scaler, Scaling, Size, Spatial, Temporal,
ToneMapping, backend,
};
#[path = "assemble.rs"]
mod assemble;
#[path = "deliver.rs"]
mod deliver;
#[path = "looks.rs"]
mod looks;
#[derive(Debug, Clone, Copy, PartialEq)]
struct DitherArg(Option<DitherMethod>);
impl FromStr for DitherArg {
type Err = ffcine::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == "none" {
Ok(Self(None))
} else {
s.parse().map(|m| Self(Some(m)))
}
}
}
#[derive(Debug, Clone, Bpaf)]
#[allow(clippy::large_enum_variant, clippy::doc_markdown)] pub(crate) enum Cmd {
#[bpaf(command)]
Probe {
#[bpaf(positional("FILE"))]
file: PathBuf,
},
#[bpaf(command)]
Grade(#[bpaf(external(grade_args))] GradeArgs),
#[bpaf(command)]
Batch {
#[bpaf(argument("DIR"))]
cache_dir: Option<PathBuf>,
no_cache: bool,
#[bpaf(positional("FILE"))]
file: PathBuf,
},
#[bpaf(command, long("stabilise"))]
Stabilize(#[bpaf(external(stabilize_args))] StabilizeArgs),
#[bpaf(command)]
Jitter(#[bpaf(external(jitter_args))] JitterArgs),
#[bpaf(command)]
FetchLuts {
#[bpaf(argument("DIR"))]
dir: Option<PathBuf>,
force: bool,
},
#[bpaf(command)]
Import(#[bpaf(external(import_args))] ImportArgs),
#[bpaf(command)]
Triage(#[bpaf(external(crate::triage::triage_args))] crate::triage::TriageArgs),
#[bpaf(command)]
#[allow(clippy::doc_markdown)]
Chapters {
#[bpaf(argument("TITLE"), fallback("Intro".to_owned()))]
intro: String,
#[bpaf(short('o'), argument("FILE"))]
output: Option<PathBuf>,
#[bpaf(positional("TIMELINE"))]
timeline: PathBuf,
},
#[bpaf(command)]
Credits {
public: bool,
#[bpaf(short('o'), argument("FILE"))]
output: Option<PathBuf>,
#[bpaf(positional("TIMELINE"))]
timeline: PathBuf,
},
#[bpaf(command)]
Still(#[bpaf(external(deliver::still_args))] deliver::StillArgs),
#[bpaf(command)]
Looks(#[bpaf(external(looks::looks_args))] looks::LooksArgs),
#[bpaf(command)]
Subs {
#[bpaf(argument("SECONDS"), fallback(0.0))]
offset: f64,
#[bpaf(argument("TIMELINE"))]
into: PathBuf,
#[bpaf(short('o'), argument("FILE"))]
output: Option<PathBuf>,
#[bpaf(positional("SUBS"))]
subs: PathBuf,
},
#[bpaf(command)]
Render(#[bpaf(external(assemble::render_args))] assemble::RenderArgs),
#[bpaf(command)]
Diff {
#[bpaf(argument("NAME"), fallback("preview".to_owned()))]
profile: String,
#[bpaf(argument("ASPECT"))]
render_profile: Option<String>,
all: bool,
#[bpaf(positional("OLD"))]
old: PathBuf,
#[bpaf(positional("NEW"))]
new: PathBuf,
},
}
fn split_words(line: &str) -> anyhow::Result<Vec<String>> {
let mut words = Vec::new();
let mut cur: Option<String> = None;
let mut quote = None;
for c in line.chars() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(None, '\'' | '"') => {
quote = Some(c);
cur.get_or_insert_default();
}
(None, '#') if cur.is_none() => break,
(None, c) if c.is_whitespace() => words.extend(cur.take()),
(_, c) => cur.get_or_insert_default().push(c),
}
}
anyhow::ensure!(quote.is_none(), "unterminated quote");
words.extend(cur);
Ok(words)
}
fn report(output: &std::path::Path, s: &ffcine::RunStats) {
let wall = s.wall.as_secs_f64();
#[allow(clippy::cast_precision_loss)]
let fps = s.frames as f64 / wall;
eprintln!(
"{}: {} frames in {wall:.2} s ({fps:.1} fps, steady {:.1}, setup {:.2} s)",
output.display(),
s.frames,
s.steady_fps().unwrap_or(fps),
s.setup.as_secs_f64()
);
}
#[derive(Debug, Clone, Bpaf)]
pub(crate) struct ImportArgs {
#[bpaf(argument("FORMAT"))]
format: Option<ffcine::import::Format>,
#[bpaf(argument::<String>("FPS"), parse(|s: String| parse_rate(&s)), optional)]
rate: Option<f64>,
#[bpaf(long("size"), long("frame-size"), argument("WxH"))]
size: Option<Size>,
strict: bool,
#[bpaf(short('o'), long("output"), argument("FILE"))]
output: PathBuf,
#[bpaf(positional("FILE"))]
input: PathBuf,
}
fn parse_rate(s: &str) -> anyhow::Result<f64> {
let r = match s.split_once('/') {
Some((n, d)) => n.trim().parse::<f64>()? / d.trim().parse::<f64>()?,
None => s.trim().parse()?,
};
anyhow::ensure!(r.is_finite() && r > 0.0, "bad frame rate {s:?}");
Ok(r)
}
fn probe_duration(path: &Path) -> Option<f64> {
let info = backend::Ffmpeg::new().ok()?.probe(path).ok()?;
info.duration.map(|d| d.as_secs_f64())
}
fn import(args: &ImportArgs) -> anyhow::Result<()> {
let opts = ffcine::import::ImportOptions {
frame_size: args.size.map(|s| (s.width, s.height)),
rate: args.rate,
media_duration: Some(probe_duration),
beats: Some(crate::audio::beat_times),
strict: args.strict,
};
let imported = ffcine::import::import(&args.input, args.format, &opts)
.with_context(|| format!("importing {}", args.input.display()))?;
eprint!("{}", imported.report);
imported.timeline.to_file(&args.output)?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Rate(Rational);
impl std::str::FromStr for Rate {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
let (n, d) = s.split_once('/').unwrap_or((s, "1"));
match (n.parse(), d.parse()) {
(Ok(num), Ok(den)) if num > 0 && den > 0 => Ok(Self(Rational { num, den })),
_ => Err(format!("invalid rate {s:?}, expected e.g. 24000/1001")),
}
}
}
impl std::fmt::Display for Rate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
const NTSC_FILM: Rate = Rate(Rational {
num: 24000,
den: 1001,
});
#[derive(Debug, Clone, Bpaf)]
#[allow(clippy::doc_markdown)] pub(crate) struct StabilizeArgs {
#[bpaf(argument("SECONDS"), fallback(0.0))]
start: f64,
#[bpaf(argument("N"))]
frames: usize,
#[bpaf(argument("NUM/DEN"), fallback(NTSC_FILM), display_fallback)]
rate: Rate,
#[bpaf(argument("FRAMES"), fallback(20), display_fallback)]
smoothing: u32,
#[bpaf(argument("PX"), fallback(1920), display_fallback)]
analysis_width: u32,
#[bpaf(argument("Q"), fallback(FlowQuality::Slow), display_fallback)]
quality: FlowQuality,
#[bpaf(argument("Z"))]
max_zoom: Option<f64>,
#[bpaf(argument("Z"))]
zoom: Option<f64>,
no_cache: bool,
#[bpaf(argument("FILE"))]
hook: Option<PathBuf>,
#[bpaf(argument("N"), fallback(1), display_fallback)]
hook_passes: u32,
#[bpaf(argument("FILE"))]
transforms: Option<PathBuf>,
#[bpaf(argument("FILE"))]
motions: Option<PathBuf>,
#[bpaf(positional("INPUT"))]
input: PathBuf,
}
#[derive(Debug, Clone, Bpaf)]
pub(crate) struct JitterArgs {
#[bpaf(argument("SECONDS"), fallback(0.0))]
start: f64,
#[bpaf(argument("N"))]
frames: usize,
#[bpaf(argument("NUM/DEN"), fallback(NTSC_FILM), display_fallback)]
rate: Rate,
#[bpaf(argument("FRAMES"), fallback(5.0), display_fallback)]
sigma: f64,
#[bpaf(argument("FRAME"), many)]
exclude: Vec<usize>,
#[bpaf(argument("PX"), fallback(1920), display_fallback)]
analysis_width: u32,
#[bpaf(positional("INPUT"))]
input: PathBuf,
}
impl StabilizeArgs {
fn run(self) -> anyhow::Result<()> {
let range = SourceRange {
start: self.start,
frames: self.frames,
rate: self.rate.0,
};
let detect = DetectParams {
analysis_width: self.analysis_width,
quality: self.quality,
..DetectParams::default()
};
let t = std::time::Instant::now();
let (det, cached) = if self.no_cache {
(stabilize::detect(&self.input, &range, &detect)?, false)
} else {
stabilize::detect_cached(&self.input, &range, &detect)?
};
println!(
"detect {:.2} s{} ({}x{} analysed at {}x{})",
t.elapsed().as_secs_f64(),
if cached { " (cached)" } else { "" },
det.width,
det.height,
det.analysis_width,
det.analysis_height,
);
let params = StabilizeParams {
smoothing: self.smoothing,
zoom: match self.zoom {
Some(z) => Zoom::Fixed(z),
None => Zoom::Auto { max: self.max_zoom },
},
};
let st = stabilize::stabilize(&det, ¶ms);
println!("zoom {:.4} (needed {:.4})", st.zoom, st.min_zoom);
let write = |path: &PathBuf, text: String| {
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))
};
if let Some(p) = &self.motions {
write(p, stabilize::motions_text(&det.motions))?;
}
if let Some(p) = &self.transforms {
write(p, stabilize::transforms_text(&st.transforms))?;
}
if let Some(p) = &self.hook {
let hook =
stabilize::hook::warp_hook(&st.transforms, det.width, det.height, self.hook_passes);
write(p, hook)?;
}
Ok(())
}
}
impl JitterArgs {
fn run(self) -> anyhow::Result<()> {
let range = SourceRange {
start: self.start,
frames: self.frames,
rate: self.rate.0,
};
let detect = DetectParams {
analysis_width: self.analysis_width,
..DetectParams::default()
};
let det = stabilize::detect(&self.input, &range, &detect)?;
let j = stabilize::jitter(&det.motions, self.sigma, &self.exclude);
println!(
"jitter tx {:.3} px ty {:.3} px angle {:.4} deg ({} pairs, mean confidence {:.2})",
j.tx, j.ty, j.angle, j.pairs, j.confidence
);
Ok(())
}
}
#[derive(Debug, Clone, Bpaf)]
#[allow(clippy::struct_excessive_bools, clippy::doc_markdown)]
pub(crate) struct GradeArgs {
#[bpaf(argument("SECONDS"), fallback(0.0))]
ss: f64,
#[bpaf(argument("N"))]
frames: Option<u64>,
#[bpaf(argument("RATE"))]
fps: Option<Rational>,
#[bpaf(argument("M/P/T/R"))]
in_color: Option<ColorOverride>,
#[bpaf(argument("PROFILE"))]
camera: Option<ProfileChoice>,
#[bpaf(argument("FILE"))]
lut: Option<PathBuf>,
#[bpaf(argument("TYPE"), fallback(LutType::Normalized), display_fallback)]
lut_type: LutType,
#[bpaf(argument("CURVE"), fallback(ToneMapping::Bt2390), display_fallback)]
tonemap: ToneMapping,
#[bpaf(argument("MODE"), fallback(GamutMapping::Perceptual), display_fallback)]
gamut: GamutMapping,
#[bpaf(argument("S"), fallback(0.30), display_fallback)]
contrast_recovery: f32,
#[bpaf(argument("S"), fallback(0.80), display_fallback)]
perceptual_strength: f32,
no_peak_detect: bool,
deband: bool,
no_deband: bool,
#[bpaf(argument("METHOD"), fallback(DitherArg(Some(DitherMethod::Blue))))]
dither: DitherArg,
temporal_dither: bool,
#[bpaf(argument("W:H:X:Y"))]
crop: Option<Crop>,
#[bpaf(argument("WxH"))]
size: Option<Size>,
#[bpaf(argument("FILTER"), fallback(Scaler::Lanczos), display_fallback)]
upscaler: Scaler,
#[bpaf(argument("FILTER"), fallback(Scaler::Lanczos), display_fallback)]
downscaler: Scaler,
linear: bool,
sigmoid: bool,
#[bpaf(argument("AMOUNT"), fallback(0.0))]
sharpen: f32,
#[bpaf(argument("STRENGTH"))]
adaptive_sharpen: Option<f32>,
#[bpaf(argument("PROFILE"))]
render_profile: Option<Profile>,
#[bpaf(argument("JSON"))]
reframe: Option<String>,
safe_area: bool,
#[bpaf(argument("FILE"), many)]
shader: Vec<PathBuf>,
#[bpaf(long("stabilize"), long("stabilise"))]
stabilize: bool,
#[bpaf(argument("FRAMES"), fallback(20), display_fallback)]
stab_smoothing: u32,
#[bpaf(argument("Z"))]
stab_zoom: Option<f64>,
#[bpaf(argument("Z"))]
stab_max_zoom: Option<f64>,
#[bpaf(argument("N"), fallback(0))]
temporal_radius: u32,
#[bpaf(argument("SRC"), fallback(ffcine::filters::Motion::Nvof))]
temporal_motion: ffcine::filters::Motion,
#[bpaf(argument("S"), fallback(4.0))]
temporal_strength: f32,
#[bpaf(argument("S"))]
temporal_chroma: Option<f32>,
#[bpaf(argument("S"), fallback(0.0))]
denoise: f32,
#[bpaf(argument("S"))]
denoise_chroma: Option<f32>,
#[bpaf(argument("R"), fallback(2))]
denoise_radius: u32,
#[bpaf(argument("S"), fallback(0.0))]
grain: f32,
#[bpaf(argument("PX"), fallback(1.5))]
grain_size: f32,
#[bpaf(argument("K"), fallback(0.0))]
grain_chroma: f32,
preview: bool,
#[bpaf(argument("CODEC"))]
codec: Option<Codec>,
#[bpaf(argument("PRESET"))]
preset: Option<String>,
#[bpaf(argument("TUNE"))]
tune: Option<String>,
#[bpaf(argument("PROFILE"))]
profile: Option<String>,
#[bpaf(argument("RC"))]
rate: Option<RateControl>,
#[bpaf(argument("FMT"))]
pix_fmt: Option<PixelFormat>,
#[bpaf(argument("N"))]
gop: Option<u32>,
#[bpaf(argument("PROFILE"))]
deliver: Option<ffcine::DeliveryProfile>,
#[bpaf(argument("MB"))]
budget_mb: Option<f64>,
#[bpaf(argument("TIMELINE"))]
text: Option<PathBuf>,
#[bpaf(argument("SECONDS"), fallback(0.0))]
text_at: f64,
#[bpaf(argument("N"))]
cuda: Option<u32>,
#[bpaf(argument("N"))]
vulkan: Option<u32>,
#[bpaf(argument("API"), fallback(Decode::Vulkan), display_fallback)]
decode: Decode,
#[bpaf(argument("DIR"))]
cache_dir: Option<PathBuf>,
no_cache: bool,
#[bpaf(positional("INPUT"))]
input: PathBuf,
#[bpaf(positional("OUTPUT"))]
output: PathBuf,
}
impl GradeArgs {
fn stabilize(&self) -> Option<StabilizeParams> {
self.stabilize.then_some(StabilizeParams {
smoothing: self.stab_smoothing,
zoom: match self.stab_zoom {
Some(z) => Zoom::Fixed(z),
None => Zoom::Auto {
max: self.stab_max_zoom,
},
},
})
}
fn build(self, ff: &dyn ffcine::Backend) -> anyhow::Result<Pipeline> {
let stab = self.stabilize();
let choice = match self.camera {
Some(c) => Some(c),
None if self.lut.is_none() => Some(ProfileChoice::Auto),
None => None,
};
let no_deband = self.no_deband;
let mut p = self.into_pipeline()?;
if let Some(choice) = choice {
let info = ff
.probe(&p.input)
.with_context(|| format!("probing {}", p.input.display()))?;
let profile = choice.resolve(&info);
eprintln!("profile: {profile} ({choice})");
if let Some(cam) = profile.camera_lut(&camera::lut_dirs())? {
if let Some(ffcine::Stage::Grade(g)) = p.stages.first_mut() {
if profile.as_str().starts_with("dlog") && !no_deband {
g.deband.get_or_insert_with(Deband::default);
}
g.input_color.primaries.get_or_insert(cam.primaries);
g.input_color.transfer.get_or_insert(cam.transfer);
g.input_lut = Some(LutStage {
lut: cam.lut,
kind: LutType::Normalized,
});
}
}
}
if let Some(params) = stab {
let t = std::time::Instant::now();
let (st, cached) = ffcine::stabilize_pipeline(ff, &mut p, ¶ms, true)
.with_context(|| format!("stabilizing {}", p.input.display()))?;
eprintln!(
"stabilize: detect {:.2} s{}, {} frames, zoom {:.4} (needed {:.4})",
t.elapsed().as_secs_f64(),
if cached { " (cached)" } else { "" },
st.transforms.len(),
st.zoom,
st.min_zoom
);
}
Ok(p)
}
fn apply_reframe(&self, grade: &mut Grade) -> anyhow::Result<()> {
let info = backend::Ffmpeg::new()?
.probe(&self.input)
.with_context(|| format!("probing {}", self.input.display()))?;
let video = info
.streams
.iter()
.find_map(|s| match &s.kind {
ffcine::probe::StreamKind::Video(v) => Some(v.clone()),
_ => None,
})
.context("no video stream")?;
let rate = self
.fps
.or(video.frame_rate)
.map_or(24000.0 / 1001.0, |r| f64::from(r.num) / f64::from(r.den));
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let frames = self.frames.unwrap_or_else(|| {
let d = info.duration.map_or(0.0, |d| d.as_secs_f64());
((d - self.ss).max(0.0) * rate).ceil() as u64
});
let mut out = self.render_profile.clone().unwrap_or_else(|| {
let s = self.size.unwrap_or(Size {
width: video.width,
height: video.height,
});
Profile::new("custom", s.width, s.height)
});
if self.safe_area {
out.safe_area = Some(out.default_safe_area());
}
let reframe = match self.reframe.as_deref() {
None => Reframe::default(),
Some(s) => {
let text = match s.strip_prefix('@') {
Some(f) => {
std::fs::read_to_string(f).with_context(|| format!("reading {f}"))?
}
None => s.to_owned(),
};
let v: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&text).context("--reframe: expected a JSON object")?;
Reframe::parse(&v, Some(&out.name))?
}
};
let shot = Shot {
width: video.width,
height: video.height,
frames,
rate,
start: self.ss,
};
let windows = reframe.frames(&shot, &out);
ffcine::reframe::pass::apply(grade, &windows, &[], (video.width, video.height), &out);
if let Some(c) = self.crop {
grade.crops.clear();
grade.crop = Some(c);
}
ffcine::hooks::order(&mut grade.hooks);
Ok(())
}
fn into_pipeline(mut self) -> anyhow::Result<Pipeline> {
let lut = self
.lut
.take()
.map(|p| {
Lut::from_cube_file(&p).map(|lut| LutStage {
lut,
kind: self.lut_type,
})
})
.transpose()?;
let mut grade = Grade {
input_color: self.in_color.unwrap_or_default(),
lut,
tone_mapping: self.tonemap,
gamut_mapping: self.gamut,
color_map: ColorMapTuning {
contrast_recovery: self.contrast_recovery,
perceptual_strength: self.perceptual_strength,
..ColorMapTuning::default()
},
peak_detect: !self.no_peak_detect,
deband: (self.deband && !self.no_deband).then(Deband::default),
dither: self.dither.0.map(|method| Dither {
method,
temporal: self.temporal_dither,
}),
crop: self.crop,
size: self.size,
scaling: Scaling {
upscaler: self.upscaler,
downscaler: self.downscaler,
linear: self.linear,
sigmoid: self.sigmoid,
},
sharpen: self.sharpen,
shaders: std::mem::take(&mut self.shader),
denoise: Some(
Denoise {
temporal: (self.temporal_radius > 0).then_some(Temporal {
radius: self.temporal_radius,
motion: self.temporal_motion,
strength: self.temporal_strength,
chroma: self.temporal_chroma,
}),
spatial: (self.denoise > 0.0 || self.denoise_chroma.is_some_and(|c| c > 0.0))
.then_some(Spatial {
radius: self.denoise_radius,
luma: self.denoise,
chroma: self.denoise_chroma.unwrap_or(1.5 * self.denoise),
}),
}
.clamped(),
)
.filter(|d| !d.is_off()),
grain: (self.grain > 0.0).then_some(Grain {
strength: self.grain,
size: self.grain_size,
chroma: self.grain_chroma,
}),
..Grade::default()
};
if let Some(a) = self.adaptive_sharpen {
ffcine::hooks::sharpen(&mut grade, a, ffcine::hooks::Sharpen::Adaptive);
}
if self.reframe.is_some() || self.render_profile.is_some() || self.safe_area {
self.apply_reframe(&mut grade)?;
}
let mut enc = if self.preview {
EncoderSettings::x264_preview()
} else {
EncoderSettings::nvenc_hevc_master()
};
if let Some(c) = self.codec {
enc.codec = c;
}
enc.preset = self.preset.or(enc.preset);
enc.tune = self.tune.or(enc.tune);
enc.profile = self.profile.or(enc.profile);
enc.rate = self.rate.unwrap_or(enc.rate);
enc.pixel_format = self.pix_fmt.unwrap_or(enc.pixel_format);
enc.gop = self.gop.or(enc.gop);
let device = match (self.cuda, self.vulkan) {
(Some(_), Some(_)) => anyhow::bail!("--cuda and --vulkan are mutually exclusive"),
(Some(n), None) => Device::Cuda(n),
(None, Some(n)) => Device::Vulkan(n),
(None, None) => Device::Auto,
};
if !(self.ss.is_finite() && self.ss >= 0.0) {
anyhow::bail!("--ss must be a non-negative number of seconds");
}
let mut p = Pipeline::new(self.input, self.output)
.range(Duration::from_secs_f64(self.ss), self.frames)
.grade(grade)
.encoder(enc)
.device(device)
.decode(self.decode);
if let Some(r) = self.fps {
p = p.frame_rate(r);
}
Ok(p)
}
}
fn fetch_luts(dir: Option<PathBuf>, force: bool) -> anyhow::Result<()> {
let dir = dir
.or_else(|| camera::lut_dirs().into_iter().next())
.context("no LUT dir: set FFCINE_LUT_DIR or HOME")?;
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
for (name, url) in camera::VENDOR_LUTS {
let path = dir.join(name);
if path.is_file() && !force {
println!("have {}", path.display());
continue;
}
let tmp = path.with_extension("part");
let ok = std::process::Command::new("curl")
.args(["-fsSL", "--retry", "2", "-o"])
.arg(&tmp)
.arg(url)
.status()
.context("running curl")?
.success();
if !ok {
let _ = std::fs::remove_file(&tmp);
anyhow::bail!("downloading {url} failed");
}
Lut::from_cube_file(&tmp).with_context(|| format!("{url} is not a .cube LUT"))?;
std::fs::rename(&tmp, &path)?;
println!("got {}", path.display());
}
Ok(())
}
#[allow(clippy::too_many_lines)] pub(crate) fn run(cmd: Cmd) -> anyhow::Result<()> {
let mut ff = backend::Ffmpeg::new()?;
let (no_cache, cache_dir) = match &cmd {
Cmd::Grade(a) => (a.no_cache, a.cache_dir.clone()),
Cmd::Batch {
no_cache,
cache_dir,
..
} => (*no_cache, cache_dir.clone()),
Cmd::Still(a) => (a.no_cache, None),
Cmd::Looks(a) => (a.no_cache, None),
Cmd::Probe { .. }
| Cmd::FetchLuts { .. }
| Cmd::Stabilize(_)
| Cmd::Jitter(_)
| Cmd::Import(_)
| Cmd::Render(_)
| Cmd::Diff { .. }
| Cmd::Chapters { .. }
| Cmd::Credits { .. }
| Cmd::Subs { .. }
| Cmd::Triage(_) => (false, None),
};
if no_cache {
ff = ff.with_cache_dir(None);
} else if let Some(d) = cache_dir {
ff = ff.with_cache_dir(Some(d));
}
match cmd {
Cmd::Probe { file } => {
let info = ff
.probe(&file)
.with_context(|| format!("probing {}", file.display()))?;
println!("{info}");
}
Cmd::Grade(args) => deliver::grade(args, &ff)?,
Cmd::Chapters {
intro,
output,
timeline,
} => deliver::chapters(&timeline, &intro, output.as_deref())?,
Cmd::Credits {
public,
output,
timeline,
} => deliver::credits(&timeline, public, output.as_deref())?,
Cmd::Still(args) => deliver::still(args, &ff)?,
Cmd::Looks(args) => looks::looks(args, &ff)?,
Cmd::Subs {
offset,
into,
output,
subs,
} => deliver::subs(&subs, &into, output.as_deref(), offset)?,
Cmd::Batch { file, .. } => {
let text = std::fs::read_to_string(&file)
.with_context(|| format!("reading {}", file.display()))?;
let mut shots = Vec::new();
for (i, line) in text.lines().enumerate() {
let at = || format!("{}:{}", file.display(), i + 1);
let words = split_words(line).with_context(at)?;
if words.is_empty() {
continue;
}
let words: Vec<&str> = words.iter().map(String::as_str).collect();
let args = grade_args()
.to_options()
.run_inner(&words[..])
.map_err(|e| {
let msg = match e {
bpaf::ParseFailure::Stderr(d) | bpaf::ParseFailure::Stdout(d, _) => {
d.monochrome(false)
}
bpaf::ParseFailure::Completion(c) => c,
};
anyhow::anyhow!("{}: {msg}", at())
})?;
shots.push(args.build(&ff).with_context(at)?);
}
let t0 = std::time::Instant::now();
let mut frames = 0;
for p in &shots {
let s = p
.run(&ff)
.with_context(|| format!("grading {}", p.input.display()))?;
report(&p.output, &s);
frames += s.frames;
}
let wall = t0.elapsed().as_secs_f64();
#[allow(clippy::cast_precision_loss)]
let fps = frames as f64 / wall;
eprintln!(
"{} shots, {frames} frames in {wall:.2} s ({fps:.1} fps)",
shots.len()
);
}
Cmd::FetchLuts { dir, force } => fetch_luts(dir, force)?,
Cmd::Stabilize(args) => args.run()?,
Cmd::Jitter(args) => args.run()?,
Cmd::Import(args) => import(&args)?,
Cmd::Render(args) => args.run(&ff)?,
Cmd::Diff {
profile,
render_profile,
all,
old,
new,
} => assemble::diff(&profile, render_profile.as_deref(), all, &old, &new)?,
Cmd::Triage(args) => args.run()?,
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_invariants() {
cmd().to_options().check_invariants(false);
}
#[test]
fn batch_lines_split_like_a_shell() {
assert_eq!(
split_words(r#"--lut "a b.cube" in.mov 'o ut.mkv' # note"#).unwrap(),
["--lut", "a b.cube", "in.mov", "o ut.mkv"]
);
assert_eq!(
split_words(" # only a comment").unwrap(),
Vec::<String>::new()
);
assert_eq!(split_words("a#b ''").unwrap(), ["a#b", ""]);
assert!(split_words("'open").is_err());
}
#[test]
fn grade_defaults_match_prototype() {
let Cmd::Grade(args) = cmd()
.to_options()
.run_inner(&[
"grade",
"--sharpen",
"0.6",
"--cuda",
"0",
"in.mov",
"out.mkv",
])
.unwrap()
else {
panic!("expected grade");
};
let p = args.into_pipeline().unwrap();
assert_eq!(p.device, Device::Cuda(0));
assert_eq!(p.encoder, EncoderSettings::nvenc_hevc_master());
let ffcine::Stage::Grade(g) = &p.stages[0] else {
panic!("expected grade stage");
};
assert_eq!(g.tone_mapping, ToneMapping::Bt2390);
assert_eq!(g.scaling, Scaling::default());
assert!((g.sharpen - 0.6).abs() < f32::EPSILON);
assert!(g.denoise.is_none() && g.grain.is_none());
}
#[test]
fn stabilise_spelling_still_parses() {
for word in ["--stabilize", "--stabilise"] {
let words = ["grade", word, "in.mov", "out.mkv"];
let Cmd::Grade(args) = cmd().to_options().run_inner(&words[..]).unwrap() else {
panic!("not grade")
};
assert!(args.stabilize);
}
for word in ["stabilize", "stabilise"] {
let words = [word, "--frames", "10", "in.mov"];
let parsed = cmd().to_options().run_inner(&words[..]);
assert!(
matches!(parsed, Ok(Cmd::Stabilize(_))),
"{word}: {parsed:?}"
);
}
}
#[test]
fn grade_denoise_flags() {
let Cmd::Grade(args) = cmd()
.to_options()
.run_inner(&[
"grade",
"--temporal-radius",
"7",
"--denoise",
"2",
"--grain",
"3",
"in.mov",
"out.mkv",
])
.unwrap()
else {
panic!("expected grade");
};
let p = args.into_pipeline().unwrap();
let ffcine::Stage::Grade(g) = &p.stages[0] else {
panic!("expected grade stage");
};
let d = g.denoise.unwrap();
assert_eq!(d.temporal.unwrap().radius, 5);
assert!((d.spatial.unwrap().chroma - 3.0).abs() < f32::EPSILON);
assert!((g.grain.unwrap().strength - 3.0).abs() < f32::EPSILON);
}
}