Skip to main content

ffcine_reframe/
pass.rs

1//! Reframe in the grade pass: the per-frame windows become `Grade::crops` (libplacebo's own
2//! sub-pixel crop and EWA scaler), or, with a stabilizer warp, one `MAIN` hook that samples the
3//! source at `warp_i * crop_i` so warp and crop are a single resample in the same
4//! `pl_render_image` call.
5
6use std::fmt::Write as _;
7
8use ffcine_core::geom::{self, Mat3};
9use ffcine_render::{Border, Grade, Hook, ParamTrack, Size};
10use ffcine_stabilize::hook::WARP_GLSL;
11
12use crate::geo::Mapped;
13use crate::profile::{Profile, SafeArea};
14use crate::reframe::Frame;
15
16/// Hook names, so `ffcine-hooks` can order them against user shaders.
17pub const GEOMETRY_HOOK: &str = "ffcine:geometry";
18pub const SAFE_AREA_HOOK: &str = "ffcine:safe-area";
19
20const M: [&str; 9] = [
21    "m00", "m01", "m02", "m10", "m11", "m12", "m20", "m21", "m22",
22];
23
24/// Puts `frames` (one per output frame, from [`crate::Reframe::frames`]) and the stabilizer's
25/// `warps` (output -> source, before the crop; empty for none) into `grade`, rendering at `out`'s
26/// size, plus the safe-area overlay if `out` asks for it.
27///
28/// Without warps the windows are `Grade::crops`: libplacebo crops sub-pixel and scales once with
29/// the grade's scalers. With warps, `MAIN` is resampled (Catmull-Rom, as the stabilizer) at
30/// `warp_i * window_i` into an intermediate the size of the largest window, which the grade's
31/// scaler then takes to the output size; the image crop is the whole frame so the warp can reach
32/// outside the window.
33pub fn apply(
34    grade: &mut Grade,
35    frames: &[Frame],
36    warps: &[Mat3],
37    source: (u32, u32),
38    out: &Profile,
39) {
40    grade.size = Some(Size {
41        width: out.width,
42        height: out.height,
43    });
44    grade.place = frames.first().and_then(|f| f.place).map(|r| r.to_crop());
45    #[allow(clippy::cast_possible_truncation)]
46    let blur = frames.first().and_then(|f| f.blur).map(|r| r as f32);
47    grade.border = blur.map_or(Border::Black, Border::Blur);
48    if warps.is_empty() {
49        grade.crop = None;
50        grade.crops = frames.iter().map(|f| f.crop.to_crop()).collect();
51    } else {
52        grade.crop = None;
53        grade.crops.clear();
54        grade.hooks.insert(0, geometry_hook(frames, warps, source));
55    }
56    if let Some(area) = out.safe_area {
57        grade.hooks.push(safe_area_hook(&area));
58    }
59}
60
61/// Intermediate size for the warp path: the largest window, in the windows' aspect, rounded up.
62#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
63pub fn intermediate_size(frames: &[Frame]) -> (u32, u32) {
64    let w = frames.iter().map(|f| f.crop.w).fold(1.0_f64, f64::max);
65    let ar = frames.first().map_or(1.0, |f| f.crop.aspect());
66    (w.ceil() as u32, (w / ar).round().max(1.0) as u32)
67}
68
69/// Per-frame `warp_i * window_i`: intermediate pixel -> source pixel. Past the end of either
70/// list its last entry holds.
71pub fn compose(frames: &[Frame], warps: &[Mat3], iw: u32, ih: u32) -> Vec<Mat3> {
72    let n = frames.len().max(warps.len());
73    (0..n)
74        .map(|i| {
75            let crop = frames.get(i).or(frames.last()).map_or(geom::IDENTITY, |f| {
76                f.crop.to_mat3(f64::from(iw), f64::from(ih))
77            });
78            let warp = warps.get(i).or(warps.last()).unwrap_or(&geom::IDENTITY);
79            geom::mul(warp, &crop)
80        })
81        .collect()
82}
83
84/// The `MAIN` hook resampling the source at the composed matrices, given per frame as nine
85/// dynamic `//!PARAM`s (`m00` .. `m22`, row-major).
86pub fn geometry_hook(frames: &[Frame], warps: &[Mat3], (sw, sh): (u32, u32)) -> Hook {
87    let (iw, ih) = intermediate_size(frames);
88    let mats = compose(frames, warps, iw, ih);
89    let mut s = String::with_capacity(4096);
90    for (k, name) in M.iter().enumerate() {
91        let init = if k % 4 == 0 { "1.0" } else { "0.0" };
92        let _ = write!(s, "//!PARAM {name}\n//!TYPE DYNAMIC float\n{init}\n\n");
93    }
94    let _ = write!(
95        s,
96        "//!HOOK MAIN
97//!BIND HOOKED
98//!WIDTH {iw}
99//!HEIGHT {ih}
100//!DESC ffcine reframe + warp ({sw}x{sh} -> {iw}x{ih})
101#define WARP_TEX HOOKED_tex
102#define WARP_PT HOOKED_pt
103#define WARP_SIZE vec2({sw}.0, {sh}.0)
104{WARP_GLSL}
105vec4 hook() {{
106    return ffcine_warp(vec3(m00, m01, m02), vec3(m10, m11, m12), vec3(m20, m21, m22),
107                       HOOKED_pos * vec2({iw}.0, {ih}.0));
108}}
109"
110    );
111    #[allow(clippy::cast_possible_truncation)]
112    let tracks = M
113        .iter()
114        .enumerate()
115        .map(|(k, name)| ParamTrack {
116            name: (*name).to_owned(),
117            values: mats.iter().map(|m| m[k / 3][k % 3] as f32).collect(),
118        })
119        .collect();
120    Hook {
121        name: GEOMETRY_HOOK.to_owned(),
122        source: s,
123        params: Vec::new(),
124        tracks,
125    }
126}
127
128/// Puts frames with an NLE crop / transform ([`crate::geo`]) into `grade`: one `MAIN` hook
129/// samples the source at `warp_i ยท mapped_i` (Catmull-Rom), black outside the source and the
130/// crop mask, into an intermediate at the output's aspect that the grade's scaler takes to the
131/// output size. The picture fills the frame (a placed `contain` picture is part of the map),
132/// so the fill around it is black.
133pub fn apply_mapped(
134    grade: &mut Grade,
135    mapped: &[Mapped],
136    warps: &[Mat3],
137    source: (u32, u32),
138    out: &Profile,
139) {
140    grade.size = Some(Size {
141        width: out.width,
142        height: out.height,
143    });
144    grade.place = None;
145    grade.border = Border::Black;
146    grade.crop = None;
147    grade.crops.clear();
148    grade.warp = None;
149    grade.hooks.insert(
150        0,
151        mapped_hook(mapped, warps, source, (out.width, out.height)),
152    );
153    if let Some(area) = out.safe_area {
154        grade.hooks.push(safe_area_hook(&area));
155    }
156}
157
158/// Intermediate size for [`apply_mapped`]: the output size at the largest source scale along
159/// each axis (at least the output size, at most 8192 each way).
160#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
161pub fn mapped_size(mapped: &[Mapped], (ow, oh): (u32, u32)) -> (u32, u32) {
162    // Source pixels per output pixel along each output axis.
163    let k = |c: usize| {
164        mapped
165            .iter()
166            .map(|m| m.m[0][c].hypot(m.m[1][c]))
167            .fold(1.0_f64, f64::max)
168    };
169    let side = |o: u32, k: f64| (f64::from(o) * k).ceil().clamp(1.0, 8192.0) as u32;
170    (side(ow, k(0)), side(oh, k(1)))
171}
172
173const MASK: [&str; 5] = ["mx0", "my0", "mx1", "my1", "feather"];
174
175/// The `MAIN` hook of [`apply_mapped`]: per frame, the intermediate -> source map (`a00` ..
176/// `a22`), the stabilizer's warp (`m00` .. `m22`) and the crop mask, as dynamic `//!PARAM`s.
177#[allow(clippy::cast_possible_truncation)]
178pub fn mapped_hook(
179    mapped: &[Mapped],
180    warps: &[Mat3],
181    (sw, sh): (u32, u32),
182    (ow, oh): (u32, u32),
183) -> Hook {
184    let (iw, ih) = mapped_size(mapped, (ow, oh));
185    let to_out = [
186        [f64::from(ow) / f64::from(iw), 0.0, 0.0],
187        [0.0, f64::from(oh) / f64::from(ih), 0.0],
188        [0.0, 0.0, 1.0],
189    ];
190    let n = mapped.len().max(warps.len()).max(1);
191    let at = |i: usize| mapped.get(i).or(mapped.last());
192    let maps: Vec<Mat3> = (0..n)
193        .map(|i| at(i).map_or(to_out, |m| geom::mul(&m.m, &to_out)))
194        .collect();
195    let stab: Vec<Mat3> = (0..n)
196        .map(|i| *warps.get(i).or(warps.last()).unwrap_or(&geom::IDENTITY))
197        .collect();
198    let full = [0.0, 0.0, f64::from(sw), f64::from(sh), 0.0];
199    let masks: Vec<[f64; 5]> = (0..n)
200        .map(|i| {
201            at(i).map_or(full, |m| {
202                [
203                    m.mask.x,
204                    m.mask.y,
205                    m.mask.x + m.mask.w,
206                    m.mask.y + m.mask.h,
207                    m.feather,
208                ]
209            })
210        })
211        .collect();
212    let mut s = String::with_capacity(4096);
213    let mut tracks = Vec::new();
214    for (prefix, mats) in [("a", &maps), ("m", &stab)] {
215        for (k, name) in M.iter().enumerate() {
216            let name = format!("{prefix}{}", &name[1..]);
217            let init = if k % 4 == 0 { "1.0" } else { "0.0" };
218            let _ = write!(s, "//!PARAM {name}\n//!TYPE DYNAMIC float\n{init}\n\n");
219            tracks.push(ParamTrack {
220                name,
221                values: mats.iter().map(|m| m[k / 3][k % 3] as f32).collect(),
222            });
223        }
224    }
225    for (k, name) in MASK.iter().enumerate() {
226        let _ = write!(s, "//!PARAM {name}\n//!TYPE DYNAMIC float\n{}\n\n", full[k]);
227        tracks.push(ParamTrack {
228            name: (*name).to_owned(),
229            values: masks.iter().map(|m| m[k] as f32).collect(),
230        });
231    }
232    let _ = write!(
233        s,
234        "//!HOOK MAIN
235//!BIND HOOKED
236//!WIDTH {iw}
237//!HEIGHT {ih}
238//!DESC ffcine crop + transform ({sw}x{sh} -> {iw}x{ih})
239#define WARP_TEX HOOKED_tex
240#define WARP_PT HOOKED_pt
241#define WARP_SIZE vec2({sw}.0, {sh}.0)
242{WARP_GLSL}
243vec4 hook() {{
244    vec3 d = vec3(HOOKED_pos * vec2({iw}.0, {ih}.0), 1.0);
245    vec3 h = vec3(dot(vec3(a00, a01, a02), d), dot(vec3(a10, a11, a12), d),
246                  dot(vec3(a20, a21, a22), d));
247    vec2 q = h.xy / h.z;
248    vec2 e = min(q - vec2(mx0, my0), vec2(mx1, my1) - q);
249    float k = min(e.x, e.y);
250    float a = feather > 0.0 ? clamp(k / feather, 0.0, 1.0) : step(0.0, k);
251    vec4 c = ffcine_warp(vec3(m00, m01, m02), vec3(m10, m11, m12), vec3(m20, m21, m22), q);
252    return vec4(c.rgb * a, 1.0);
253}}
254"
255    );
256    Hook {
257        name: GEOMETRY_HOOK.to_owned(),
258        source: s,
259        params: Vec::new(),
260        tracks,
261    }
262}
263
264/// Review overlay at `PREOUTPUT` (RGB, after scaling, tone mapping and sharpening): dims
265/// everything outside `area` and outlines it.
266pub fn safe_area_hook(area: &SafeArea) -> Hook {
267    let SafeArea {
268        top,
269        right,
270        bottom,
271        left,
272    } = *area;
273    let source = format!(
274        "//!HOOK PREOUTPUT
275//!BIND HOOKED
276//!DESC ffcine safe area
277vec4 hook() {{
278    vec4 c = HOOKED_texOff(vec2(0.0));
279    vec2 s = HOOKED_size;
280    vec2 p = HOOKED_pos * s;
281    vec2 lo = vec2({left:.5}, {top:.5}) * s;
282    vec2 hi = vec2({:.5}, {:.5}) * s;
283    float lw = max(2.0, s.y / 540.0);
284    bool inside = all(greaterThanEqual(p, lo)) && all(lessThan(p, hi));
285    vec2 d = min(abs(p - lo), abs(p - hi));
286    bool edge = all(greaterThanEqual(p, lo - lw)) && all(lessThan(p, hi + lw)) && min(d.x, d.y) < lw;
287    if (!inside)
288        c.rgb *= 0.5;
289    if (edge)
290        c.rgb = vec3(1.0, 0.85, 0.0);
291    return c;
292}}
293",
294        1.0 - right,
295        1.0 - bottom,
296    );
297    Hook {
298        name: SAFE_AREA_HOOK.to_owned(),
299        source,
300        ..Hook::default()
301    }
302}