ffcine_assembly/
recipe.rs1use std::fmt::{self, Write as _};
8use std::path::{Path, PathBuf};
9use std::time::UNIX_EPOCH;
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13use sha2::{Digest, Sha256};
14
15pub const RENDER_REVISION: u32 = 8;
18
19pub const AUDIO_REVISION: u32 = 2;
22
23#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
25pub struct Key(pub String);
26
27impl Key {
28 pub fn of(v: &Value) -> Self {
30 let digest = Sha256::digest(canonical(v).as_bytes());
31 Self(digest[..16].iter().fold(String::new(), |mut s, b| {
32 let _ = write!(s, "{b:02x}");
33 s
34 }))
35 }
36
37 pub fn short(&self) -> &str {
39 &self.0[..self.0.len().min(10)]
40 }
41}
42
43impl fmt::Display for Key {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 f.write_str(&self.0)
46 }
47}
48
49pub fn canonical(v: &Value) -> String {
52 fn sorted(v: &Value) -> Value {
53 match v {
54 Value::Object(m) => {
55 let mut keys: Vec<&String> = m.keys().collect();
56 keys.sort();
57 Value::Object(
58 keys.into_iter()
59 .map(|k| (k.clone(), sorted(&m[k])))
60 .collect::<Map<_, _>>(),
61 )
62 }
63 Value::Array(a) => Value::Array(a.iter().map(sorted).collect()),
64 v => v.clone(),
65 }
66 }
67 sorted(v).to_string()
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct Fingerprint {
75 pub path: PathBuf,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub size: Option<u64>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub mtime_ns: Option<u128>,
80}
81
82pub trait Fingerprinter {
84 fn fingerprint(&self, path: &Path) -> Fingerprint;
85 fn content_hash(&self, path: &Path) -> Option<String>;
87 fn ffmpeg_build(&self) -> String {
90 String::from("unknown")
91 }
92}
93
94#[derive(Debug, Clone, Copy, Default)]
96pub struct FsFingerprinter;
97
98impl Fingerprinter for FsFingerprinter {
99 fn fingerprint(&self, path: &Path) -> Fingerprint {
100 let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_owned());
101 let meta = std::fs::metadata(&canon).ok();
102 Fingerprint {
103 path: canon,
104 size: meta.as_ref().map(std::fs::Metadata::len),
105 mtime_ns: meta
106 .and_then(|m| m.modified().ok())
107 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
108 .map(|d| d.as_nanos()),
109 }
110 }
111
112 fn content_hash(&self, path: &Path) -> Option<String> {
113 let bytes = std::fs::read(path).ok()?;
114 let hash = Sha256::digest(&bytes)
115 .iter()
116 .fold(String::new(), |mut s, b| {
117 let _ = write!(s, "{b:02x}");
118 s
119 });
120 Some(Key::of(&Value::String(hash)).0)
121 }
122
123 fn ffmpeg_build(&self) -> String {
125 static BUILD: std::sync::OnceLock<String> = std::sync::OnceLock::new();
126 BUILD
127 .get_or_init(|| {
128 let ffmpeg = std::env::var_os("FFMPEG").unwrap_or_else(|| "ffmpeg".into());
129 std::process::Command::new(ffmpeg)
130 .args(["-hide_banner", "-version"])
131 .output()
132 .ok()
133 .filter(|o| o.status.success())
134 .map_or_else(
135 || String::from("unavailable"),
136 |o| {
137 let out = String::from_utf8_lossy(&o.stdout);
138 let mut lines = out.lines();
139 let first = lines.next().unwrap_or("").trim().to_owned();
140 let lavfi = out
141 .lines()
142 .find(|l| l.trim_start().starts_with("libavfilter"))
143 .unwrap_or("")
144 .split_whitespace()
145 .collect::<Vec<_>>()
146 .join(" ");
147 format!("{first}; {lavfi}")
148 },
149 )
150 })
151 .clone()
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use serde_json::json;
159
160 #[test]
161 fn key_ignores_object_key_order() {
162 let a = json!({"a": 1, "b": {"x": [1, 2], "y": null}});
163 let b = json!({"b": {"y": null, "x": [1, 2]}, "a": 1});
164 assert_eq!(Key::of(&a), Key::of(&b));
165 assert_ne!(
166 Key::of(&a),
167 Key::of(&json!({"a": 2, "b": {"x": [1, 2], "y": null}}))
168 );
169 assert_eq!(Key::of(&a).0.len(), 32);
170 }
171}