use std::cell::RefCell;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use bpaf::Bpaf;
use ffcine::assembly;
use ffcine::{Decode, Device, backend};
#[derive(Debug, Clone, Bpaf)]
#[allow(clippy::doc_markdown, clippy::struct_excessive_bools)]
pub(crate) struct RenderArgs {
#[bpaf(argument("NAME"), fallback("preview".to_owned()), display_fallback)]
profile: String,
#[bpaf(argument("ASPECT"))]
render_profile: Option<String>,
#[bpaf(argument("DIR"))]
cache: Option<PathBuf>,
dry_run: bool,
no_text: bool,
#[bpaf(external(crate::audio::mix_flags))]
mix: crate::audio::MixFlags,
#[bpaf(argument("FILE|TIME"))]
cover: Option<String>,
no_chapters: bool,
json: bool,
#[bpaf(argument("N"))]
cuda: Option<u32>,
#[bpaf(argument("API"), fallback(Decode::Vulkan), display_fallback)]
decode: Decode,
#[bpaf(short('o'), long("output"), argument("FILE"))]
output: PathBuf,
#[bpaf(positional("TIMELINE"))]
timeline: PathBuf,
}
fn profile(
t: &ffcine::timeline::Timeline,
name: &str,
aspect: Option<&str>,
) -> anyhow::Result<assembly::Profile> {
let output = t.project().transpose()?.unwrap_or_default().output;
assembly::Profile::resolve(name, &output, aspect).map_err(|e| anyhow::anyhow!(e))
}
fn cache_dir(timeline: &Path, cache: Option<&Path>) -> PathBuf {
cache.map_or_else(
|| {
timeline
.parent()
.unwrap_or(Path::new("."))
.join(".ffcine-cache")
},
Path::to_owned,
)
}
fn plan_file(
path: &Path,
profile_name: &str,
aspect: Option<&str>,
no_text: bool,
cache: &Path,
) -> anyhow::Result<(assembly::Plan, Option<assembly::TextLayer>)> {
let t = ffcine::timeline::Timeline::from_file(path)
.with_context(|| format!("reading {}", path.display()))?;
let mut profile = profile(&t, profile_name, aspect)?;
if profile.proxies {
profile.proxy_dir = Some(cache.join("proxy"));
}
let profile = &profile;
let dir = path.parent().unwrap_or(Path::new("."));
let text = if no_text {
None
} else {
Some(assembly::TextLayer::from_timeline(&t, dir).context("text clips")?)
};
let fp = &assembly::FsFingerprinter;
let p = if assembly::has_comp_clips(&t) {
let ff = ffcine::backend::Ffmpeg::new()?;
let (dims, camera, rate) = (
super::comp::dims(&ff),
super::comp::camera(&ff),
super::comp::rate(&ff),
);
let media = assembly::CompMedia {
fp,
dims: &dims,
profile: Some(&camera),
rate: Some(&rate),
};
assembly::plan_with(&t, dir, profile, fp, text.as_ref(), Some(&media))
} else {
assembly::plan(&t, dir, profile, fp, text.as_ref())
}
.with_context(|| format!("planning {}", path.display()))?;
for w in &p.warnings {
eprintln!("warning: {w}");
}
Ok((p, text))
}
pub(crate) fn diff(
profile: &str,
aspect: Option<&str>,
all: bool,
old: &Path,
new: &Path,
) -> anyhow::Result<()> {
let (old, new) = (
plan_file(old, profile, aspect, false, &cache_dir(old, None))?.0,
plan_file(new, profile, aspect, false, &cache_dir(new, None))?.0,
);
print!("{}", assembly::Diff::new(&old, &new).report(&new, all));
Ok(())
}
impl RenderArgs {
fn plan(&self, cache: &Path) -> anyhow::Result<(assembly::Plan, Option<assembly::TextLayer>)> {
let (mut plan, text) = plan_file(
&self.timeline,
&self.profile,
self.render_profile.as_deref(),
self.no_text,
cache,
)?;
if let Some(a) = &mut plan.audio {
let mut o = a.options.clone();
self.mix.apply(&mut o);
a.set_options(o);
}
Ok((plan, text))
}
fn make_proxies(&self, dir: &Path) -> anyhow::Result<()> {
let t = ffcine::timeline::Timeline::from_file(&self.timeline)?;
let base = self.timeline.parent().unwrap_or(Path::new("."));
let fp = assembly::FsFingerprinter;
let mut todo: Vec<(PathBuf, PathBuf)> = Vec::new();
for p in t.placed_clips() {
if p.kind != ffcine::timeline::track_kind::VIDEO {
continue;
}
let Some(ffcine::timeline::MediaReference::External(r)) = p.clip.media_reference()
else {
continue;
};
let Some(src) = r.path().map(|m| base.join(m)) else {
continue;
};
if let Some(out) = assembly::proxy::needed(&src, dir, &fp)
&& !todo.iter().any(|(s, _)| *s == src)
{
todo.push((src, out));
}
}
let ffmpeg = std::env::var_os("FFMPEG").unwrap_or_else(|| "ffmpeg".into());
let n = todo.len();
for (i, (src, out)) in todo.iter().enumerate() {
let t0 = std::time::Instant::now();
assembly::proxy::make(&ffmpeg, src, out).map_err(|e| anyhow::anyhow!(e))?;
if !self.json {
eprintln!(
"[proxy {}/{n}] {} in {:.1} s",
i + 1,
src.display(),
t0.elapsed().as_secs_f64()
);
}
}
Ok(())
}
fn tag(&self, t: &ffcine::timeline::Timeline) -> anyhow::Result<()> {
let tags = ffcine::delivery::mp4::Tags {
chapters: if self.no_chapters {
Vec::new()
} else {
let c = ffcine::delivery::chapters(t, "Intro");
if !c.chapters.is_empty() {
for w in &c.warnings {
eprintln!("warning: {w}");
}
}
c.chapters
},
cover: match &self.cover {
Some(c) => Some(super::deliver::cover(c, &self.output)?),
None => None,
},
};
super::deliver::write_tags(&self.output, &tags)
}
pub(crate) fn run(self, ff: &backend::Ffmpeg) -> anyhow::Result<()> {
if super::comp::is_comp_file(&self.timeline) {
return self.run_comp(ff);
}
let cache = cache_dir(&self.timeline, self.cache.as_deref());
if !self.dry_run && assembly::Profile::by_name(&self.profile).is_some_and(|p| p.proxies) {
self.make_proxies(&cache.join("proxy"))?;
}
let (plan, mut text) = self.plan(&cache)?;
if self.dry_run {
dry_run(&plan, &cache);
return Ok(());
}
let t = ffcine::timeline::Timeline::from_file(&self.timeline)?;
let project = t.project().transpose()?.unwrap_or_default();
let dir = self.timeline.parent().unwrap_or(Path::new("."));
let luts = RefCell::new(super::looks::Luts::default());
let warned = RefCell::new(HashSet::new());
let grader = |s: &assembly::Shot| {
let (g, skipped) =
super::looks::grade_for(&project, dir, &s.grade, &mut luts.borrow_mut())
.map_err(|e| format!("{e:#}"))?;
for w in skipped.into_iter().filter(|w| !w.starts_with("denoise")) {
if warned.borrow_mut().insert(w.clone()) {
eprintln!("warning: {w}");
}
}
Ok(g)
};
let detect = |p: &Path, r: &ffcine::stabilize::SourceRange| {
let t = std::time::Instant::now();
let (det, cached) =
ffcine::stabilize::detect_cached(p, r, &ffcine::stabilize::DetectParams::default())
.map_err(|e| format!("{e:#}"))?;
if !self.json {
eprintln!(
"stabilize: {} frames of {}, detect {:.1} s{}",
r.frames,
p.display(),
t.elapsed().as_secs_f64(),
if cached { " (cached)" } else { "" }
);
}
Ok(det)
};
let r = assembly::Renderer {
grader: Some(&grader),
detect: Some(&detect),
backend: ff,
cache,
ffmpeg: std::env::var_os("FFMPEG").unwrap_or_else(|| "ffmpeg".into()),
device: self.cuda.map_or(Device::Auto, Device::Cuda),
decode: self.decode,
progress: if self.json {
assembly::Progress::Json
} else {
assembly::Progress::Human
},
};
let rep = r.render(&plan, text.as_mut(), &self.output)?;
self.tag(&t)?;
if !self.json {
summary(&rep);
}
Ok(())
}
fn run_comp(self, ff: &backend::Ffmpeg) -> anyhow::Result<()> {
anyhow::ensure!(
self.render_profile.is_none(),
"--render-profile doesn't apply to a comp: its canvas sets the aspect"
);
let cache = cache_dir(&self.timeline, self.cache.as_deref());
let (_, plan) = super::comp::plan(&self.timeline, &self.profile, ff)?;
if self.dry_run {
dry_run(&plan, &cache);
return Ok(());
}
let r = super::comp::renderer(ff, cache, self.cuda, self.decode, self.json);
let rep = r.render(&plan, None, &self.output)?;
if let Some(c) = &self.cover {
let tags = ffcine::delivery::mp4::Tags {
chapters: Vec::new(),
cover: Some(super::deliver::cover(c, &self.output)?),
};
super::deliver::write_tags(&self.output, &tags)?;
}
if !self.json {
summary(&rep);
}
Ok(())
}
}
fn dry_run(plan: &assembly::Plan, cache: &Path) {
let profile = &plan.profile;
let cached = |k: &assembly::Key| cache.join("seg").join(format!("{k}.mp4")).exists();
let empty = assembly::Plan {
segments: plan
.segments
.iter()
.filter(|s| cached(&s.key))
.cloned()
.collect(),
audio: plan.audio.clone().filter(|a| {
cache
.join("audio")
.join(format!("{}-aac{}.m4a", a.key, profile.audio_bitrate))
.exists()
}),
..plan.clone()
};
print!("{}", assembly::Diff::new(&empty, plan).report(plan, true));
}
fn summary(rep: &assembly::Report) {
let s = |d: Option<std::time::Duration>| {
d.map_or_else(
|| "cached".to_owned(),
|d| format!("{:.1} s", d.as_secs_f64()),
)
};
eprintln!(
"segments {:.1} s ({} of {}, {} frames), audio {}, join+mux {}, total {:.1} s",
rep.render.as_secs_f64(),
rep.rendered,
rep.segments,
rep.rendered_frames,
s(rep.audio),
s(rep.mux),
rep.total.as_secs_f64()
);
}