Skip to main content

ffcine_hooks/
lib.rs

1//! `.hook` shaders (roadmap step 9, `docs/design.md#roadmap`, `docs/hooks.md`): mpv user shaders
2//! run in the libplacebo grade pass through `pl_mpv_user_shader_parse`, as
3//! [`ffcine_render::Hook`]s with their `//!PARAM`s set from the timeline.
4//!
5//! - Vendored: [`ADAPTIVE_SHARPEN`] (BSD-2-Clause, header kept), the default sharpen, replacing
6//!   the generated unsharp hook (`ffcine_render::shaders::unsharp`), which stays for prototype
7//!   diffs ([`Sharpen::Unsharp`]); ArtCNN's luma doublers ([`ARTCNN_C4F16`], MIT).
8//! - Not vendored (GPL/LGPL): KrigBilateral and FSRCNNX are known by name ([`EXTERNAL`]) and
9//!   loaded from user-supplied files on the shader path ([`shader_path`]).
10//! - Timeline: `ffcine.fx.shader/1 {shader, params}` with `shader` an alias in
11//!   `metadata.ffcine.project.shaders`, a vendored name or a path; `params` constants or
12//!   keyframes ([`shader`]).
13//! - Order: [`order`] sorts a grade's hooks by stage, then geometry, sharpen, user shaders and the
14//!   review overlay within a stage.
15//!
16//! Our own filters (denoise, grain) are compute passes in `ffcine-filters` instead.
17
18pub 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
30/// bacondither's Adaptive-sharpen (mpv port by igv) at `PREOUTPUT`, with `curve_height` and
31/// `overshoot_ctrl` as `//!PARAM`s. Licence: BSD-2-Clause (header in the file).
32pub const ADAPTIVE_SHARPEN: &str = include_str!("../shaders/adaptive-sharpen.glsl");
33
34/// ArtCNN C4F16 (Artoriuz), a CNN luma doubler at `LUMA`: runs when the output is more than
35/// 1.3x the source on both axes. Licence: MIT (header in the file, `shaders/LICENSE-ArtCNN`).
36pub const ARTCNN_C4F16: &str = include_str!("../shaders/artcnn-c4f16.glsl");
37
38/// [`ARTCNN_C4F16`] trained to also denoise and sharpen.
39pub const ARTCNN_C4F16_DS: &str = include_str!("../shaders/artcnn-c4f16-ds.glsl");
40
41/// Vendored shaders by name.
42pub 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/// A shader known by name whose licence keeps it out of ffcine (Apache-2.0): the user downloads
52/// `file` from `url` into a [`shader_path`] directory.
53#[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
61/// Shaders loaded by name from the [`shader_path`], not shipped.
62pub 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
83/// Directories searched for [`EXTERNAL`] shaders: `FFCINE_SHADER_PATH` (`:`-separated), then
84/// `$XDG_DATA_HOME/ffcine/shaders` (default `~/.local/share/ffcine/shaders`).
85pub 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
97/// The [`EXTERNAL`] shader `name`'s path and text, from the first of `dirs` holding its file.
98/// `None` if `name` isn't external; an error naming the download if no directory has it.
99pub 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
122/// A shader by name, vendored or [`EXTERNAL`] (on the [`shader_path`]), as a [`Hook`] named
123/// after it. `None` if the name is neither.
124pub 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
138/// Moves `//!HOOK OUTPUT` passes to `PREOUTPUT`. mpv runs `OUTPUT` on RGB; libplacebo runs it
139/// after encoding to the target's colour system, which for ffcine's YUV outputs is YCbCr, so an
140/// RGB shader there would sharpen chroma and tint the picture. `PREOUTPUT` is the RGB stage mpv
141/// shaders expect.
142pub 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/// Which sharpener a `sharpen` amount drives.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
158pub enum Sharpen {
159    /// [`ADAPTIVE_SHARPEN`] at `PREOUTPUT` with `curve_height = amount`.
160    #[default]
161    Adaptive,
162    /// The prototype's `unsharp=5:5:amount` at `PREOUTPUT` (`Grade::sharpen`), for diffs.
163    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
176/// Sets `grade`'s sharpening to `amount` (0 = off) with `kind`, replacing any earlier one.
177pub 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
197/// Within a stage: the reframe/warp geometry, then sharpening, then user shaders (in timeline
198/// order: grain after sharpening), then the review overlay on top.
199fn 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
208/// Sorts hooks by their first stage ([`parse::STAGES`]), then by role; stable otherwise.
209/// libplacebo runs each hook at its own stage regardless, so this only decides the order of
210/// hooks sharing a stage, and makes the array read in pipeline order.
211pub 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}