use std::io::Write as _;
use std::path::{Component, Path, PathBuf};
use anyhow::Context as _;
use bpaf::Bpaf;
use ffcine::assembly;
use ffcine::comp::{Base, Comp, Source};
use ffcine::{Backend, backend};
#[derive(Debug, Clone, Bpaf)]
#[allow(clippy::doc_markdown)] pub(crate) enum CompCmd {
#[bpaf(command, footer(crate::help::COMP_CHECK))]
Check {
#[bpaf(argument("NAME"), fallback("preview".to_owned()), display_fallback)]
profile: String,
#[bpaf(positional("COMP"))]
comp: PathBuf,
},
#[bpaf(command, footer(crate::help::COMP_LOWER))]
Lower {
#[bpaf(short('o'), long("output"), argument("FILE"))]
output: Option<PathBuf>,
#[bpaf(positional("COMP"))]
comp: PathBuf,
},
#[bpaf(command, footer(crate::help::COMP_NEW))]
New {
#[bpaf(argument("NAME"))]
template: String,
#[bpaf(argument("N"))]
tiles: Option<usize>,
#[bpaf(argument("S"), fallback(6.0), display_fallback)]
duration: f64,
#[bpaf(argument("FILE"), some("at least one --media FILE"))]
media: Vec<PathBuf>,
#[bpaf(short('o'), long("output"), argument("FILE"))]
output: PathBuf,
},
}
fn lower(path: &Path) -> anyhow::Result<serde_json::Value> {
let ctx = || format!("reading {}", path.display());
let doc = ffcine::comp::read_document(path).with_context(ctx)?;
let out = ffcine::comp::templates::expand(&doc).with_context(ctx)?;
Comp::from_value(&out, path.parent().unwrap_or(Path::new("."))).with_context(ctx)?;
Ok(out)
}
fn relative(p: &Path, dir: &Path) -> anyhow::Result<String> {
let cwd = std::env::current_dir()?;
let (p, dir) = (normalize(&cwd.join(p)), normalize(&cwd.join(dir)));
let (mut pc, mut dc) = (p.components().peekable(), dir.components().peekable());
while let (Some(a), Some(b)) = (pc.peek(), dc.peek())
&& a == b
{
pc.next();
dc.next();
}
let shared = p.components().count() - pc.clone().count();
let rel: PathBuf = if shared == 0 {
p.clone()
} else {
dc.map(|_| Component::ParentDir).chain(pc).collect()
};
rel.to_str()
.map(str::to_owned)
.ok_or_else(|| anyhow::anyhow!("{}: not UTF-8", p.display()))
}
fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir
if matches!(out.components().next_back(), Some(Component::Normal(_))) =>
{
out.pop();
}
c => out.push(c),
}
}
out
}
fn new(
template: &str,
tiles: Option<usize>,
duration: f64,
media: &[PathBuf],
out: &Path,
) -> anyhow::Result<()> {
if template != "story" {
anyhow::bail!("unknown template {template:?} for comp new (story)");
}
let dir = out
.parent()
.filter(|d| !d.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let media = media
.iter()
.map(|p| relative(p, dir))
.collect::<anyhow::Result<Vec<_>>>()?;
let tiles = tiles.unwrap_or(media.len().min(3));
let doc = ffcine::comp::templates::story::starter(&media, tiles, duration)?;
Comp::from_value(&doc, dir).context("checking the new comp")?;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(out)
.map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => {
anyhow::anyhow!("{} exists; not overwriting it", out.display())
}
_ => anyhow::Error::new(e).context(format!("writing {}", out.display())),
})?;
f.write_all((serde_json::to_string_pretty(&doc)? + "\n").as_bytes())
.with_context(|| format!("writing {}", out.display()))?;
println!("wrote {}", out.display());
Ok(())
}
pub(crate) fn is_comp_file(path: &Path) -> bool {
ffcine::comp::is_comp_file(path)
}
pub(crate) fn load(path: &Path) -> anyhow::Result<Comp> {
Comp::from_file(path).with_context(|| format!("reading {}", path.display()))
}
pub(crate) fn dims(ff: &dyn Backend) -> impl Fn(&Path) -> ffcine::Result<(u32, u32)> + '_ {
move |p: &Path| {
let info = ff.probe(p)?;
let (_, v) = info.video().ok_or_else(|| ffcine::Error::Invalid {
what: "layer source (no picture stream)",
value: p.display().to_string(),
})?;
Ok((v.width, v.height))
}
}
pub(crate) fn rate(ff: &dyn Backend) -> impl Fn(&Path) -> ffcine::Result<(f64, u64)> + '_ {
move |p: &Path| {
let info = ff.probe(p)?;
let bad = || ffcine::Error::Invalid {
what: "time remap source (no frame rate or length)",
value: p.display().to_string(),
};
let (_, v) = info.video().ok_or_else(bad)?;
let fps = v
.frame_rate
.map(ffcine::Rational::as_f64)
.filter(|r| r.is_finite() && *r > 0.0)
.ok_or_else(bad)?;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let frames = v
.frames
.filter(|n| *n > 0)
.or_else(|| {
info.duration
.map(|d| (d.as_secs_f64() * fps).round() as u64)
.filter(|n| *n > 0)
})
.ok_or_else(bad)?;
Ok((fps, frames))
}
}
pub(crate) fn camera(
ff: &dyn Backend,
) -> impl Fn(&Path) -> ffcine::Result<ffcine::camera::Profile> + '_ {
move |p: &Path| Ok(ffcine::camera::Profile::detect_file(&ff.probe(p)?))
}
pub(crate) fn profile(c: &Comp, name: &str) -> anyhow::Result<assembly::Profile> {
let [w, h] = c.canvas.size;
assembly::Profile::for_canvas(name, w, h).map_err(|e| anyhow::anyhow!(e))
}
pub(crate) fn plan(
path: &Path,
name: &str,
ff: &dyn Backend,
) -> anyhow::Result<(Comp, assembly::Plan)> {
let c = load(path)?;
let p = profile(&c, name)?;
let dims = dims(ff);
let camera = camera(ff);
let rate = rate(ff);
let media = assembly::CompMedia {
fp: &assembly::FsFingerprinter,
dims: &dims,
profile: Some(&camera),
rate: Some(&rate),
};
let plan = assembly::plan_comp(&c, &p, &media)
.with_context(|| format!("planning {}", path.display()))?;
Ok((c, plan))
}
pub(crate) fn renderer(
ff: &backend::Ffmpeg,
cache: PathBuf,
cuda: Option<u32>,
decode: ffcine::Decode,
json: bool,
) -> assembly::Renderer<'_> {
assembly::Renderer {
grader: None,
detect: None,
backend: ff,
cache,
ffmpeg: std::env::var_os("FFMPEG").unwrap_or_else(|| "ffmpeg".into()),
device: cuda.map_or(ffcine::Device::Auto, ffcine::Device::Cuda),
decode,
progress: if json {
assembly::Progress::Json
} else {
assembly::Progress::Human
},
}
}
#[allow(clippy::cast_precision_loss)]
pub(crate) fn run(cmd: CompCmd, ff: &backend::Ffmpeg) -> anyhow::Result<()> {
match cmd {
CompCmd::Lower { output, comp } => {
let text = serde_json::to_string_pretty(&lower(&comp)?)? + "\n";
match output {
Some(o) => {
std::fs::write(&o, text).with_context(|| format!("writing {}", o.display()))?;
}
None => print!("{text}"),
}
}
CompCmd::New {
template,
tiles,
duration,
media,
output,
} => new(&template, tiles, duration, &media, &output)?,
CompCmd::Check { profile, comp } => {
let (c, plan) = plan(&comp, &profile, ff)?;
let cv = &c.canvas;
println!(
"canvas {}x{} at {} fps, {} s ({} frames); rendered at {}x{} ({})",
cv.size[0],
cv.size[1],
cv.fps,
cv.duration,
c.frames(),
plan.profile.size.width,
plan.profile.size.height,
plan.profile.name
);
let probe = dims(ff);
for (i, l) in c.layers.iter().enumerate() {
if c.span(l).is_empty() {
eprintln!(
"warning: layer {i} is outside the canvas (in {} s): never drawn, its \
tracks aren't sampled",
l.in_
);
}
let src = match &l.source {
Source::Video(p) => {
let (w, h) = probe(p).with_context(|| format!("layer {i}"))?;
format!("video {} ({w}x{h})", p.display())
}
Source::Still(p) => {
let (w, h) = probe(p).with_context(|| format!("layer {i}"))?;
format!("still {} ({w}x{h})", p.display())
}
Source::Solid(_) => "solid".to_owned(),
Source::Text { .. } => "text".to_owned(),
Source::Comp(n) => format!(
"comp ({}x{}, {} layers)",
n.canvas.size[0],
n.canvas.size[1],
n.layers.len()
),
};
println!(
"layer {i}{}: {src}, {:.3}-{:.3} s, {}x{} box",
l.id.as_deref()
.map(|s| format!(" ({s})"))
.unwrap_or_default(),
l.in_,
l.out,
l.size[0],
l.size[1]
);
}
for s in &plan.segments {
let assembly::Unit::Comp(p) = &s.unit else {
continue;
};
println!(
"piece {} ({} frames): {} layers{} {}",
s.name,
s.frames,
p.shown.len(),
match p.base {
Base::Video { .. } => ", bottom video promoted to the pass input",
Base::Black => "",
},
s.key.short()
);
}
println!("ok: {} pieces, every frame sampled", plan.segments.len());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn media_paths_are_relative_to_the_output() {
let rel = |p: &str, d: &str| relative(Path::new(p), Path::new(d)).expect("utf-8");
assert_eq!(rel("/a/b/c.mp4", "/a/b"), "c.mp4");
assert_eq!(rel("/a/b/c.mp4", "/a/out"), "../b/c.mp4");
assert_eq!(rel("/a/b/c.mp4", "/x/y"), "../../a/b/c.mp4");
assert_eq!(rel("/a/./b/../c.mp4", "/a/d/."), "../c.mp4");
assert_eq!(rel("c.mp4", "."), "c.mp4");
assert_eq!(rel("c.mp4", "out"), "../c.mp4");
}
}