use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::json;
use crate::recipe::{Fingerprinter, Key};
pub const SHORT_SIDE: u32 = 720;
pub fn sibling(source: &Path) -> Option<PathBuf> {
["LRF", "lrf"]
.iter()
.map(|ext| source.with_extension(ext))
.find(|p| p.is_file() && p.as_path() != source)
}
pub fn cached(dir: &Path, source: &Path, fp: &dyn Fingerprinter) -> PathBuf {
let key = Key::of(&json!({"proxy": fp.fingerprint(source), "short": SHORT_SIDE}));
dir.join(format!("{key}.mp4"))
}
pub fn find(source: &Path, dir: Option<&Path>, fp: &dyn Fingerprinter) -> Option<PathBuf> {
sibling(source).or_else(|| dir.map(|d| cached(d, source, fp)).filter(|p| p.is_file()))
}
fn is_video(path: &Path) -> bool {
path.extension()
.and_then(OsStr::to_str)
.map(str::to_ascii_lowercase)
.is_some_and(|e| {
matches!(
e.as_str(),
"mp4" | "mov" | "m4v" | "mkv" | "mts" | "mxf" | "avi" | "webm"
)
})
}
pub fn needed(source: &Path, dir: &Path, fp: &dyn Fingerprinter) -> Option<PathBuf> {
if !is_video(source) || !source.is_file() || sibling(source).is_some() {
return None;
}
Some(cached(dir, source, fp)).filter(|p| !p.is_file())
}
fn scale(filter: &str) -> String {
let s = SHORT_SIDE;
format!("{filter}=w='if(gt(iw,ih),-2,min({s},iw))':h='if(gt(iw,ih),min({s},ih),-2)'")
}
pub fn args(source: &Path, out: &Path, cuda: bool) -> Vec<OsString> {
let mut v: Vec<OsString> = ["-v", "error", "-y", "-noautorotate"]
.iter()
.map(OsString::from)
.collect();
if cuda {
v.extend(
["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"]
.iter()
.map(OsString::from),
);
}
v.push("-i".into());
v.push(source.into());
v.extend(
["-map", "0:v:0", "-an", "-sn", "-dn", "-vf"]
.iter()
.map(OsString::from),
);
if cuda {
v.push(scale("scale_cuda").into());
v.extend(
[
"-c:v",
"hevc_nvenc",
"-preset",
"p1",
"-rc",
"constqp",
"-qp",
"20",
]
.iter()
.map(OsString::from),
);
} else {
v.push(scale("scale").into());
v.extend(
["-c:v", "libx264", "-preset", "veryfast", "-crf", "18"]
.iter()
.map(OsString::from),
);
}
v.extend(["-g", "24", "-f", "mp4"].iter().map(OsString::from));
v.push(out.into());
v
}
pub fn make(ffmpeg: &OsStr, source: &Path, out: &Path) -> Result<(), String> {
if let Some(d) = out.parent() {
std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?;
}
let partial = out.with_extension("partial.mp4");
let mut last = String::new();
for cuda in [true, false] {
match Command::new(ffmpeg)
.args(args(source, &partial, cuda))
.output()
{
Ok(o) if o.status.success() => {
return std::fs::rename(&partial, out)
.map_err(|e| format!("{}: {e}", out.display()));
}
Ok(o) => String::from_utf8_lossy(&o.stderr)
.trim()
.clone_into(&mut last),
Err(e) => last = format!("{}: {e}", ffmpeg.to_string_lossy()),
}
}
let _ = std::fs::remove_file(&partial);
Err(format!("proxy of {}: {last}", source.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::FsFingerprinter;
#[test]
fn lrf_beside_the_clip_wins() {
let dir = std::env::temp_dir().join(format!("ffcine-proxy-{}", std::process::id()));
let cache = dir.join("cache");
std::fs::create_dir_all(&cache).expect("dir");
let (a, b) = (dir.join("A001.MP4"), dir.join("B001.MOV"));
for p in [&a, &b, &dir.join("A001.LRF")] {
std::fs::write(p, b"x").expect("write");
}
let fp = FsFingerprinter;
assert_eq!(find(&a, Some(&cache), &fp), Some(dir.join("A001.LRF")));
assert_eq!(needed(&a, &cache, &fp), None);
let made = needed(&b, &cache, &fp).expect("needs one");
assert!(made.starts_with(&cache));
assert_eq!(find(&b, Some(&cache), &fp), None);
std::fs::write(&made, b"x").expect("write");
assert_eq!(find(&b, Some(&cache), &fp), Some(made.clone()));
assert_eq!(needed(&b, &cache, &fp), None);
std::fs::write(&b, b"longer").expect("write");
assert_ne!(cached(&cache, &b, &fp), made);
assert_eq!(needed(&dir.join("song.wav"), &cache, &fp), None);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn makes_a_small_proxy() {
if Command::new("ffmpeg").arg("-version").output().is_err() {
eprintln!("skipped: no ffmpeg");
return;
}
let dir = std::env::temp_dir().join(format!("ffcine-proxy-make-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("dir");
let src = dir.join("tall.mp4");
let ok = Command::new("ffmpeg")
.args(["-v", "error", "-y", "-f", "lavfi", "-i"])
.arg("testsrc2=s=1080x1920:r=24:d=1")
.args(["-c:v", "mpeg4"])
.arg(&src)
.status()
.expect("ffmpeg");
assert!(ok.success());
let out = dir.join("proxy.mp4");
make(OsStr::new("ffmpeg"), &src, &out).expect("proxy");
let probe = Command::new("ffprobe")
.args(["-v", "error", "-select_streams", "v:0", "-show_entries"])
.args(["stream=width,height", "-of", "csv=p=0"])
.arg(&out)
.output()
.expect("ffprobe");
assert_eq!(String::from_utf8_lossy(&probe.stdout).trim(), "720,1280");
let _ = std::fs::remove_dir_all(dir);
}
}