1pub mod grade;
19pub mod parse;
20pub mod shader;
21
22use std::path::{Path, PathBuf};
23
24use ffcine_core::{Error, Result};
25use ffcine_render::{Grade, Hook};
26
27pub use parse::{HookInfo, ParamInfo, ParamKind, parse};
28pub use shader::ShaderEffect;
29
30pub const ADAPTIVE_SHARPEN: &str = include_str!("../shaders/adaptive-sharpen.glsl");
33
34pub const ARTCNN_C4F16: &str = include_str!("../shaders/artcnn-c4f16.glsl");
37
38pub const ARTCNN_C4F16_DS: &str = include_str!("../shaders/artcnn-c4f16-ds.glsl");
40
41pub fn vendored(name: &str) -> Option<&'static str> {
43 match name {
44 "adaptive-sharpen" => Some(ADAPTIVE_SHARPEN),
45 "artcnn-c4f16" => Some(ARTCNN_C4F16),
46 "artcnn-c4f16-ds" => Some(ARTCNN_C4F16_DS),
47 _ => None,
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct External {
55 pub name: &'static str,
56 pub file: &'static str,
57 pub url: &'static str,
58 pub licence: &'static str,
59}
60
61pub const EXTERNAL: &[External] = &[
63 External {
64 name: "krigbilateral",
65 file: "KrigBilateral.glsl",
66 url: "https://gist.github.com/igv/a015fc885d5c22e6891820ad89555637",
67 licence: "LGPL-3.0-or-later",
68 },
69 External {
70 name: "fsrcnnx",
71 file: "FSRCNNX_x2_8-0-4-1.glsl",
72 url: "https://github.com/igv/FSRCNN-TensorFlow/releases",
73 licence: "GPL-3.0",
74 },
75 External {
76 name: "fsrcnnx-16",
77 file: "FSRCNNX_x2_16-0-4-1.glsl",
78 url: "https://github.com/igv/FSRCNN-TensorFlow/releases",
79 licence: "GPL-3.0",
80 },
81];
82
83pub fn shader_path() -> Vec<PathBuf> {
86 let mut dirs: Vec<PathBuf> = std::env::var_os("FFCINE_SHADER_PATH")
87 .map(|p| std::env::split_paths(&p).collect())
88 .unwrap_or_default();
89 let data = std::env::var_os("XDG_DATA_HOME")
90 .map(PathBuf::from)
91 .filter(|p| p.is_absolute())
92 .or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share")));
93 dirs.extend(data.map(|d| d.join("ffcine/shaders")));
94 dirs
95}
96
97pub fn external(name: &str, dirs: &[PathBuf]) -> Option<Result<(PathBuf, String)>> {
100 let e = EXTERNAL.iter().find(|e| e.name == name)?;
101 let Some(path) = dirs.iter().map(|d| d.join(e.file)).find(|p| p.is_file()) else {
102 let dirs: Vec<String> = dirs.iter().map(|d| d.display().to_string()).collect();
103 return Some(Err(Error::Import {
104 path: PathBuf::from(e.file),
105 msg: format!(
106 "{} is {} and not bundled with ffcine: download it from {} into one of [{}] \
107 (or a directory on FFCINE_SHADER_PATH)",
108 e.name,
109 e.licence,
110 e.url,
111 dirs.join(", ")
112 ),
113 }));
114 };
115 Some(
116 std::fs::read_to_string(&path)
117 .map(|t| (path.clone(), t))
118 .map_err(|source| Error::Io { path, source }),
119 )
120}
121
122pub fn named(name: &str) -> Option<Result<Hook>> {
125 let source = match vendored(name) {
126 Some(t) => Ok(t.to_owned()),
127 None => external(name, &shader_path())?.map(|(_, t)| retarget_output(&t)),
128 };
129 Some(source.map(|source| Hook {
130 name: name.to_owned(),
131 source,
132 ..Hook::default()
133 }))
134}
135
136pub const SHARPEN_HOOK: &str = "ffcine:sharpen";
137
138pub fn retarget_output(text: &str) -> String {
143 text.lines()
144 .map(|l| {
145 if l.trim_end() == "//!HOOK OUTPUT" {
146 "//!HOOK PREOUTPUT"
147 } else {
148 l
149 }
150 })
151 .collect::<Vec<_>>()
152 .join("\n")
153 + "\n"
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
158pub enum Sharpen {
159 #[default]
161 Adaptive,
162 Unsharp,
164}
165
166impl Sharpen {
167 pub fn parse(s: &str) -> Option<Self> {
168 match s {
169 "adaptive" | "adaptive-sharpen" => Some(Self::Adaptive),
170 "unsharp" => Some(Self::Unsharp),
171 _ => None,
172 }
173 }
174}
175
176pub fn sharpen(grade: &mut Grade, amount: f32, kind: Sharpen) {
178 grade.hooks.retain(|h| h.name != SHARPEN_HOOK);
179 grade.sharpen = 0.0;
180 if amount <= 0.0 {
181 return;
182 }
183 match kind {
184 Sharpen::Unsharp => grade.sharpen = amount,
185 Sharpen::Adaptive => {
186 grade.hooks.push(Hook {
187 name: SHARPEN_HOOK.to_owned(),
188 source: ADAPTIVE_SHARPEN.to_owned(),
189 params: vec![("curve_height".to_owned(), f64::from(amount))],
190 tracks: Vec::new(),
191 });
192 order(&mut grade.hooks);
193 }
194 }
195}
196
197fn role(h: &Hook) -> u8 {
200 match h.name.as_str() {
201 ffcine_reframe::pass::GEOMETRY_HOOK => 0,
202 SHARPEN_HOOK => 1,
203 ffcine_reframe::pass::SAFE_AREA_HOOK => 3,
204 _ => 2,
205 }
206}
207
208pub fn order(hooks: &mut [Hook]) {
212 hooks.sort_by_cached_key(|h| {
213 let stage = parse(&h.source).map_or(parse::STAGES.len(), |i| i.first_stage());
214 (stage, role(h))
215 });
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use ffcine_reframe::{Profile, pass};
222
223 fn user(name: &str, stage: &str) -> Hook {
224 Hook {
225 name: name.to_owned(),
226 source: format!(
227 "//!HOOK {stage}\n//!BIND HOOKED\nvec4 hook() {{ return HOOKED_texOff(0); }}\n"
228 ),
229 ..Hook::default()
230 }
231 }
232
233 #[test]
234 fn output_hooks_move_to_preoutput() {
235 let t = retarget_output("//!HOOK OUTPUT\n//!BIND HOOKED\n//!HOOK MAIN\n");
236 assert_eq!(t, "//!HOOK PREOUTPUT\n//!BIND HOOKED\n//!HOOK MAIN\n");
237 assert_eq!(parse(ADAPTIVE_SHARPEN).unwrap().stages, ["PREOUTPUT"]);
238 }
239
240 #[test]
241 fn licence_header_kept() {
242 assert!(ADAPTIVE_SHARPEN.starts_with("// Copyright (c) 2015-2021, bacondither\n"));
243 assert!(ADAPTIVE_SHARPEN.contains("THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS''"));
244 assert_eq!(vendored("adaptive-sharpen"), Some(ADAPTIVE_SHARPEN));
245 for s in [ARTCNN_C4F16, ARTCNN_C4F16_DS] {
246 assert!(s.starts_with("// MIT License\n"));
247 assert!(s.contains("The above copyright notice and this permission notice"));
248 assert!(parse(s).unwrap().stages.iter().all(|s| s == "LUMA"));
249 }
250 assert_eq!(vendored("artcnn-c4f16-ds"), Some(ARTCNN_C4F16_DS));
251 }
252
253 #[test]
254 fn external_from_shader_path() {
255 let dir =
256 std::env::temp_dir().join(format!("ffcine-hooks-external-{}", std::process::id()));
257 std::fs::create_dir_all(&dir).unwrap();
258 let dirs = [dir.join("missing"), dir.clone()];
259 assert!(external("adaptive-sharpen", &dirs).is_none());
260 let err = external("krigbilateral", &dirs).unwrap().unwrap_err();
261 assert!(err.to_string().contains("LGPL-3.0-or-later"), "{err}");
262 assert!(err.to_string().contains("gist.github.com/igv"), "{err}");
263 let src = "//!HOOK CHROMA\n//!BIND HOOKED\nvec4 hook() { return HOOKED_texOff(0); }\n";
264 std::fs::write(dir.join("KrigBilateral.glsl"), src).unwrap();
265 let (path, text) = external("krigbilateral", &dirs).unwrap().unwrap();
266 assert_eq!((path, text.as_str()), (dir.join("KrigBilateral.glsl"), src));
267 let _ = std::fs::remove_dir_all(&dir);
268 assert_eq!(named("artcnn-c4f16").unwrap().unwrap().source, ARTCNN_C4F16);
269 assert!(named("nope").is_none());
270 }
271
272 #[test]
273 fn sharpen_replaces_unsharp() {
274 let mut g = Grade::default();
275 sharpen(&mut g, 0.6, Sharpen::Adaptive);
276 assert!(g.sharpen == 0.0 && g.hooks.len() == 1);
277 assert_eq!(
278 g.hooks[0].params,
279 [("curve_height".to_owned(), f64::from(0.6_f32))]
280 );
281 sharpen(&mut g, 0.45, Sharpen::Unsharp);
282 assert!(g.hooks.is_empty() && (g.sharpen - 0.45).abs() < f32::EPSILON);
283 sharpen(&mut g, 0.0, Sharpen::Adaptive);
284 assert!(g.hooks.is_empty() && g.sharpen == 0.0);
285 }
286
287 #[test]
288 fn hooks_in_pipeline_order() {
289 let mut reel = Profile::preset("reel").expect("preset");
290 reel.safe_area = Some(reel.default_safe_area());
291 let r = ffcine_reframe::Reframe::default();
292 let shot = ffcine_reframe::Shot {
293 width: 3840,
294 height: 2160,
295 frames: 10,
296 rate: 24.0,
297 start: 0.0,
298 };
299 let mut g = Grade::default();
300 g.hooks.push(user("grain", "PREOUTPUT"));
301 g.hooks.push(user("krig", "CHROMA"));
302 pass::apply(
303 &mut g,
304 &r.frames(&shot, &reel),
305 &[ffcine_core::geom::IDENTITY],
306 (3840, 2160),
307 &reel,
308 );
309 sharpen(&mut g, 1.0, Sharpen::Adaptive);
310 let names: Vec<&str> = g.hooks.iter().map(|h| h.name.as_str()).collect();
311 assert_eq!(
312 names,
313 [
314 "krig",
315 pass::GEOMETRY_HOOK,
316 SHARPEN_HOOK,
317 "grain",
318 pass::SAFE_AREA_HOOK
319 ]
320 );
321 }
322}