use std::f64::consts::FRAC_PI_2;
use ffcine_reframe::Keyed;
use ffcine_render::BlendMode;
use serde_json::{Map, Value, json};
fn f(m: &Map<String, Value>, k: &str) -> Option<f64> {
m.get(k).and_then(Value::as_f64)
}
pub fn colour(v: Option<&Value>, default: [f64; 3]) -> [f64; 3] {
match v {
Some(Value::String(s)) if s == "white" => [1.0; 3],
Some(Value::String(s)) if s == "black" => [0.0; 3],
Some(Value::Array(a)) if a.len() >= 3 => {
let c = |i: usize| a[i].as_f64().unwrap_or(0.0).clamp(0.0, 1.0);
[c(0), c(1), c(2)]
}
_ => default,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Finish {
pub vignette: f64,
pub darken: f64,
pub fade_in: f64,
pub fade_out: f64,
pub fade_colour: [f64; 3],
pub flash: Option<(f64, Vec<f64>)>,
}
impl Default for Finish {
fn default() -> Self {
Self {
vignette: 0.0,
darken: 1.0,
fade_in: 0.0,
fade_out: 0.0,
fade_colour: [0.0; 3],
flash: None,
}
}
}
impl Finish {
pub fn add(&mut self, schema: (&str, u32), m: &Map<String, Value>) -> bool {
match schema {
("ffcine.fx.vignette", 1) => self.vignette = f(m, "amount").unwrap_or(0.0),
("ffcine.fx.darken", 1) => self.darken = f(m, "amount").unwrap_or(1.0),
("ffcine.fx.fade", 1) => {
self.fade_in = f(m, "in").unwrap_or(0.0).max(0.0);
self.fade_out = f(m, "out").unwrap_or(0.0).max(0.0);
self.fade_colour = colour(m.get("colour"), [0.0; 3]);
}
("ffcine.fx.flash", 1) => {
let levels = m.get("levels").and_then(Value::as_array).map_or_else(
|| vec![0.5, 0.5, 0.2],
|a| a.iter().filter_map(Value::as_f64).collect(),
);
self.flash = Some((f(m, "at").unwrap_or(0.0), levels));
}
_ => return false,
}
true
}
pub fn is_identity(&self) -> bool {
*self == Self::default()
}
pub fn is_timed(&self) -> bool {
self.fade_in > 0.0 || self.fade_out > 0.0 || self.flash.is_some()
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
pub fn tracks(&self, at: i64, n: i64, len: i64, rate: f64) -> (Vec<f32>, Vec<f32>) {
let flash_at = self
.flash
.as_ref()
.map(|(s, l)| ((s * rate).round() as i64, l));
(at..at + n)
.map(|i| {
let t = i as f64 / rate;
let mut k: f64 = 1.0;
if self.fade_in > 0.0 {
k = k.min(t / self.fade_in);
}
if self.fade_out > 0.0 {
let st = len as f64 / rate - self.fade_out;
k = k.min(1.0 - (t - st) / self.fade_out);
}
let lift = flash_at
.and_then(|(s, l)| usize::try_from(i - s).ok().and_then(|j| l.get(j)))
.copied()
.unwrap_or(0.0);
(k.clamp(0.0, 1.0) as f32, lift as f32)
})
.unzip()
}
pub fn vignette_angle(&self) -> f64 {
self.vignette * FRAC_PI_2
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TransitionKind {
Dissolve,
Dip([f64; 3]),
Flash,
Whip([f64; 2]),
}
impl TransitionKind {
pub fn from_meta(m: Option<&Map<String, Value>>) -> Self {
let Some(m) = m else { return Self::Dissolve };
match m.get("kind").and_then(Value::as_str) {
Some("dip") => Self::Dip(colour(m.get("colour"), [0.0; 3])),
Some("flash") => {
let c = colour(m.get("colour"), [1.0; 3]);
if c.iter().all(|x| (x - 1.0).abs() < 1e-9) {
Self::Flash
} else {
Self::Dip(c)
}
}
Some("whip") => Self::Whip(match m.get("direction").and_then(Value::as_str) {
Some("right") => [1.0, 0.0],
Some("up") => [0.0, -1.0],
Some("down") => [0.0, 1.0],
_ => [-1.0, 0.0],
}),
_ => Self::Dissolve,
}
}
pub fn recipe(&self) -> Value {
match self {
Self::Dissolve => json!("dissolve"),
Self::Dip(c) => json!({"dip": c}),
Self::Flash => json!("flash"),
Self::Whip(d) => json!({"whip": d}),
}
}
}
#[allow(clippy::cast_precision_loss)]
pub fn progress(i: i64, n: i64) -> f64 {
((i as f64 + 0.5) / n.max(1) as f64).clamp(0.0, 1.0)
}
pub fn smoothstep(p: f64) -> f64 {
let p = p.clamp(0.0, 1.0);
p * p * (3.0 - 2.0 * p)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Layout {
pub kind: String,
pub gap: f64,
pub margin: f64,
pub radius: f64,
pub weights: Vec<f64>,
pub stagger: f64,
pub slide: f64,
pub enter: bool,
pub first_static: bool,
}
impl Layout {
pub fn parse(m: &Map<String, Value>) -> Self {
Self {
kind: m
.get("layout")
.and_then(Value::as_str)
.unwrap_or("split")
.to_owned(),
gap: f(m, "gap").unwrap_or(0.008),
margin: f(m, "margin").unwrap_or(0.0),
radius: f(m, "radius").unwrap_or(0.012),
weights: m
.get("weights")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_f64).collect())
.unwrap_or_default(),
stagger: f(m, "stagger").unwrap_or(0.22),
slide: f(m, "slide").unwrap_or(0.42),
enter: m.get("enter").and_then(Value::as_str) != Some("none"),
first_static: m
.get("first_static")
.and_then(Value::as_bool)
.unwrap_or(true),
}
}
pub fn radius_px(&self, h: u32) -> f64 {
(self.radius * f64::from(h)).round()
}
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
#[allow(clippy::many_single_char_names)]
pub fn rects(&self, k: usize, w: u32, h: u32) -> Vec<Rect> {
let (fw, fh) = (f64::from(w), f64::from(h));
let gap = (self.gap * fh).round();
let mar = (self.margin * fh).round();
let even = |x: f64| (x / 2.0).floor() * 2.0;
let split = |avail: f64, k: usize, weights: &[f64]| -> Vec<f64> {
let wts: Vec<f64> = if weights.len() == k {
weights.to_vec()
} else {
vec![1.0; k]
};
let sum: f64 = wts.iter().sum();
let mut v: Vec<f64> = wts
.iter()
.map(|x| even((avail * x / sum).floor()))
.collect();
if v.pop().is_some() {
let rest: f64 = v.iter().sum();
v.push(even(avail - rest));
}
v
};
let row = |x0: f64, y0: f64, avail_w: f64, ph: f64, k: usize, wts: &[f64]| {
let mut x = x0;
split(avail_w, k, wts)
.into_iter()
.map(|pw| {
let r = Rect {
x,
y: y0,
w: pw,
h: ph,
};
x += pw + gap;
r
})
.collect::<Vec<_>>()
};
let col = |x0: f64, y0: f64, pw: f64, avail_h: f64, k: usize, wts: &[f64]| {
let mut y = y0;
split(avail_h, k, wts)
.into_iter()
.map(|ph| {
let r = Rect {
x: x0,
y,
w: pw,
h: ph,
};
y += ph + gap;
r
})
.collect::<Vec<_>>()
};
let kf = k.max(1) as f64;
match self.kind.as_str() {
"column" => col(
mar,
mar,
even(fw - 2.0 * mar),
fh - 2.0 * mar - gap * (kf - 1.0),
k,
&self.weights,
),
"1+2" if k == 3 => {
let wts = if self.weights.len() == 2 {
self.weights.clone()
} else {
vec![1.0, 1.0]
};
let cols = row(
mar,
mar,
fw - 2.0 * mar - gap,
even(fh - 2.0 * mar),
2,
&wts,
);
let mut v = vec![cols[0]];
v.extend(col(cols[1].x, mar, cols[1].w, fh - 2.0 * mar - gap, 2, &[]));
v
}
_ => row(
mar,
mar,
fw - 2.0 * mar - gap * (kf - 1.0),
even(fh - 2.0 * mar),
k,
&self.weights,
),
}
}
#[allow(clippy::many_single_char_names)]
pub fn enter_offset(&self, i: usize, t: f64, w: u32, h: u32) -> [f64; 2] {
if !self.enter || (i == 0 && self.first_static) {
return [0.0; 2];
}
#[allow(clippy::cast_precision_loss)]
let t0 = i as f64 * self.stagger - if self.first_static { 0.0 } else { 0.16 };
let u = ((t - t0) / self.slide.max(1e-6)).clamp(0.0, 1.0);
let e = 1.0 - (1.0 - u).powi(3);
let d = if i % 2 == 0 { 1.0 } else { -1.0 };
if self.kind == "column" {
[d * f64::from(w) * (1.0 - e), 0.0]
} else {
[0.0, d * f64::from(h) * (1.0 - e)]
}
}
}
pub fn cover(sw: f64, sh: f64, w: f64, h: f64, focus: [f64; 2], zoom: f64) -> Rect {
let (mut cw, mut ch) = if sw / sh > w / h {
(sh * w / h, sh)
} else {
(sw, sw * h / w)
};
let z = zoom.max(1e-3);
cw /= z;
ch /= z;
Rect {
x: (sw - cw) * focus[0].clamp(0.0, 1.0),
y: (sh - ch) * focus[1].clamp(0.0, 1.0),
w: cw,
h: ch,
}
}
#[derive(Debug, Clone)]
pub struct Placement {
picture_units: bool,
tx: Keyed<f64>,
ty: Keyed<f64>,
sx: Keyed<f64>,
sy: Keyed<f64>,
rotate: Keyed<f64>,
anchor: Keyed<[f64; 2]>,
opacity: Keyed<f64>,
pub mode: BlendMode,
pub radius: f64,
}
impl Placement {
pub fn parse(
transform: &Map<String, Value>,
blend: &Map<String, Value>,
warn: &mut dyn FnMut(String),
) -> Self {
let mut key = |m: &Map<String, Value>, k: &'static str, d: f64| match m.get(k) {
None => Keyed::Const(d),
Some(v) => Keyed::parse(v, k).unwrap_or_else(|e| {
warn(format!("{k}: {e}"));
Keyed::Const(d)
}),
};
let tx = key(transform, "translate_x", 0.0);
let ty = key(transform, "translate_y", 0.0);
let sx = key(transform, "scale_x", 1.0);
let sy = key(transform, "scale_y", 1.0);
let rotate = key(transform, "rotate_deg", 0.0);
let opacity = key(blend, "opacity", 1.0);
let anchor = match transform.get("anchor") {
None => Keyed::Const([0.0; 2]),
Some(v) => Keyed::parse(v, "anchor").unwrap_or_else(|e| {
warn(format!("anchor: {e}"));
Keyed::Const([0.0; 2])
}),
};
let mode = match blend.get("mode").and_then(Value::as_str) {
None => BlendMode::Normal,
Some(m) => m.parse().unwrap_or_else(|_| {
warn(format!("blend mode {m} rendered as normal"));
BlendMode::Normal
}),
};
Self {
picture_units: transform.get("units").and_then(Value::as_str) == Some("picture"),
tx,
ty,
sx,
sy,
rotate,
anchor,
opacity,
mode,
radius: f(blend, "radius").unwrap_or(0.0),
}
}
#[allow(clippy::many_single_char_names)]
pub fn rect(&self, t: f64, sw: f64, sh: f64, w: u32, h: u32) -> Rect {
let (fw, fh) = (f64::from(w), f64::from(h));
let fit = (fw / sw).min(fh / sh);
let ev = |k: &Keyed<f64>, d| k.eval(t).unwrap_or(d);
let (s0, s1) = (ev(&self.sx, 1.0), ev(&self.sy, 1.0));
let picture = [sw * fit, sh * fit];
let unit = if self.picture_units {
picture
} else {
[fw, fh]
};
let [ax, ay] = self.anchor.eval(t).unwrap_or([0.0; 2]);
let a = [ax * unit[0], ay * unit[1]];
let (sin, cos) = self.rotation(t).to_radians().sin_cos();
let z = [-a[0] * s0, -a[1] * s1];
let turned = [cos * z[0] + sin * z[1], -sin * z[0] + cos * z[1]];
let (pw, ph) = (picture[0] * s0, picture[1] * s1);
let (cx, cy) = (
fw / 2.0 + a[0] + turned[0] + ev(&self.tx, 0.0) * unit[0],
fh / 2.0 + a[1] + turned[1] + ev(&self.ty, 0.0) * unit[1],
);
Rect {
x: cx - pw / 2.0,
y: cy - ph / 2.0,
w: pw,
h: ph,
}
}
pub fn rotation(&self, t: f64) -> f64 {
self.rotate.eval(t).unwrap_or(0.0)
}
pub fn opacity(&self, t: f64) -> f64 {
self.opacity.eval(t).unwrap_or(1.0).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::needless_pass_by_value)]
fn obj(v: Value) -> Map<String, Value> {
v.as_object().cloned().unwrap()
}
#[test]
fn fades_follow_ffmpeg() {
let mut fin = Finish::default();
assert!(fin.add(
("ffcine.fx.fade", 1),
&obj(json!({"in": 1.0, "out": 0.5, "colour": "white"}))
));
assert!(fin.is_timed());
assert_eq!(fin.fade_colour, [1.0; 3]);
let (k, lift) = fin.tracks(0, 48, 48, 24.0);
assert!(k[0].abs() < 1e-6);
assert!((k[12] - 0.5).abs() < 1e-6);
assert!((k[30] - 1.0).abs() < 1e-6);
assert!((k[47] - 1.0 / 12.0).abs() < 1e-5);
assert!(lift.iter().all(|l| *l == 0.0));
let (k2, _) = fin.tracks(40, 8, 48, 24.0);
assert_eq!(k2, k[40..]);
}
#[test]
fn flash_lifts_frames() {
let mut fin = Finish::default();
fin.add(("ffcine.fx.flash", 1), &obj(json!({"at": 0.5})));
let (_, lift) = fin.tracks(10, 6, 100, 24.0);
assert_eq!(lift, [0.0, 0.0, 0.5, 0.5, 0.2, 0.0]);
}
#[test]
fn transition_kinds() {
let k = |v: Value| TransitionKind::from_meta(Some(&obj(v)));
assert_eq!(k(json!({"kind": "dip"})), TransitionKind::Dip([0.0; 3]));
assert_eq!(
k(json!({"kind": "dip", "colour": "white"})),
TransitionKind::Dip([1.0; 3])
);
assert_eq!(k(json!({"kind": "flash"})), TransitionKind::Flash);
assert_eq!(
k(json!({"kind": "whip", "direction": "up"})),
TransitionKind::Whip([0.0, -1.0])
);
assert_eq!(k(json!({"kind": "wipe"})), TransitionKind::Dissolve);
assert_eq!(TransitionKind::from_meta(None), TransitionKind::Dissolve);
}
#[test]
fn layout_rects_match_render_py() {
let l = Layout::parse(&obj(json!({"layout": "split"})));
let r = l.rects(2, 1920, 1080);
assert_eq!((r[0].x, r[0].w, r[0].h), (0.0, 954.0, 1080.0));
assert_eq!((r[1].x, r[1].w), (963.0, 956.0));
let r3 = l.rects(3, 1920, 1080);
assert_eq!(r3.len(), 3);
assert!(r3[2].x + r3[2].w <= 1920.0);
let one_two =
Layout::parse(&obj(json!({"layout": "1+2", "gap": 0.01}))).rects(3, 1920, 1080);
assert_eq!(one_two[0].h, 1080.0);
assert_eq!(one_two[1].x, one_two[2].x);
assert!(one_two[2].y > one_two[1].y);
let col = Layout::parse(&obj(json!({"layout": "column"}))).rects(2, 1080, 1920);
assert_eq!(col[0].w, 1080.0);
assert!(col[1].y > col[0].y);
}
#[test]
fn panels_slide_in() {
let l = Layout::parse(&obj(json!({})));
assert_eq!(l.enter_offset(0, 0.0, 1920, 1080), [0.0; 2]);
let start = l.enter_offset(1, 0.0, 1920, 1080);
assert_eq!(start, [0.0, -1080.0]);
assert_eq!(l.enter_offset(1, 0.22 + 0.42, 1920, 1080), [0.0, 0.0]);
let mid = l.enter_offset(2, 0.44 + 0.21, 1920, 1080)[1];
assert!(mid > 0.0 && mid < 1080.0 * 0.2);
}
#[test]
fn cover_and_placement() {
let c = cover(3840.0, 2160.0, 960.0, 1080.0, [0.5, 0.5], 1.0);
assert!((c.w - 1920.0).abs() < 1e-9 && (c.x - 960.0).abs() < 1e-9);
let mut warns = Vec::new();
let p = Placement::parse(
&obj(json!({"translate_x": 0.25, "scale_x": 0.5, "scale_y": 0.5})),
&obj(json!({"opacity": [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 1.0}], "radius": 0.02})),
&mut |w| warns.push(w),
);
assert_eq!(warns, Vec::<String>::new());
let r = p.rect(0.0, 1920.0, 1080.0, 1920, 1080);
assert_eq!((r.x, r.y, r.w, r.h), (960.0, 270.0, 960.0, 540.0));
assert!((p.opacity(0.5) - 0.5).abs() < 1e-9);
assert_eq!(p.mode, BlendMode::Normal);
assert!(p.rotation(0.0).abs() < 1e-12);
}
#[test]
fn rotation_pivots_on_the_anchor() {
let mut warns = Vec::new();
let p = Placement::parse(
&obj(
json!({"units": "picture", "anchor": [-0.5, -0.5], "scale_x": 0.5,
"scale_y": 0.5, "rotate_deg": 90.0}),
),
&obj(json!({"mode": "multiply"})),
&mut |w| warns.push(w),
);
assert_eq!(warns, Vec::<String>::new());
assert_eq!(p.mode, BlendMode::Multiply);
assert!((p.rotation(0.0) - 90.0).abs() < 1e-12);
let r = p.rect(0.0, 1920.0, 1080.0, 1920, 1080);
let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
assert!((r.w - 960.0).abs() < 1e-9 && (r.h - 540.0).abs() < 1e-9);
assert!((cx - 270.0).abs() < 1e-9, "{cx}");
assert!((cy + 480.0).abs() < 1e-9, "{cy}");
let q = ffcine_import::resolve::Placement {
anchor: [-0.5, -0.5],
scale: [0.5, 0.5],
rotate_deg: 90.0,
..Default::default()
}
.apply([1920.0, 1080.0], [0.0, 0.0]);
assert!((cx - 960.0 - q[0]).abs() < 1e-9 && (cy - 540.0 - q[1]).abs() < 1e-9);
let k = Placement::parse(
&obj(
json!({"rotate_deg": [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 30.0}],
"anchor": [0.1, 0.0]}),
),
&obj(json!({"mode": "resolve:27"})),
&mut |w| warns.push(w),
);
assert!((k.rotation(0.5) - 15.0).abs() < 1e-9);
assert_eq!(k.mode, BlendMode::Normal);
assert_eq!(warns.len(), 1);
let r0 = k.rect(0.0, 1920.0, 1080.0, 1920, 1080);
assert!(r0.x.abs() < 1e-9 && r0.y.abs() < 1e-9);
}
}