#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::many_single_char_names)]
use ff_filter::{XfadeTransition, xfade_frand};
pub fn apply_xfade(
kind: XfadeTransition,
a: &[u8],
b: &[u8],
alpha: f32,
dims: (u32, u32),
dissolve_field: Option<&[f32]>,
dst: &mut Vec<u8>,
) {
let (w, h) = dims;
let expected = (w as usize) * (h as usize) * 4;
if a.len() != b.len() || a.len() != expected {
blend_rgba(a, b, alpha, dst);
return;
}
let p = alpha.clamp(0.0, 1.0);
let (wf, hf) = (w as f32, h as f32);
match kind {
XfadeTransition::WipeRight => {
let z = (wf * p) as i64;
wipe(a, b, w, h, dst, move |x, _| i64::from(x) <= z);
}
XfadeTransition::WipeLeft => {
let z = (wf * (1.0 - p)) as i64;
wipe(a, b, w, h, dst, move |x, _| i64::from(x) > z);
}
XfadeTransition::WipeDown => {
let z = (hf * p) as i64;
wipe(a, b, w, h, dst, move |_, y| i64::from(y) <= z);
}
XfadeTransition::WipeUp => {
let z = (hf * (1.0 - p)) as i64;
wipe(a, b, w, h, dst, move |_, y| i64::from(y) > z);
}
XfadeTransition::SlideLeft => slide(a, b, w, h, dst, (p * wf) as i64, 0),
XfadeTransition::SlideRight => slide(a, b, w, h, dst, -((p * wf) as i64), 0),
XfadeTransition::SlideUp => slide(a, b, w, h, dst, 0, (p * hf) as i64),
XfadeTransition::SlideDown => slide(a, b, w, h, dst, 0, -((p * hf) as i64)),
XfadeTransition::Dissolve => dissolve(a, b, w, h, dst, p, dissolve_field),
XfadeTransition::FadeBlack => dip(a, b, [0, 0, 0], dst, p),
XfadeTransition::FadeWhite => dip(a, b, [255, 255, 255], dst, p),
_ => blend_rgba(a, b, alpha, dst),
}
}
fn wipe(a: &[u8], b: &[u8], w: u32, h: u32, dst: &mut Vec<u8>, is_b: impl Fn(u32, u32) -> bool) {
dst.resize(a.len(), 0);
for y in 0..h {
for x in 0..w {
let i = ((y * w + x) * 4) as usize;
let src = if is_b(x, y) { b } else { a };
dst[i..i + 4].copy_from_slice(&src[i..i + 4]);
}
}
}
fn slide(a: &[u8], b: &[u8], w: u32, h: u32, dst: &mut Vec<u8>, dx: i64, dy: i64) {
dst.resize(a.len(), 0);
let (wi, hi) = (i64::from(w), i64::from(h));
for y in 0..hi {
for x in 0..wi {
let (sx, sy) = (x + dx, y + dy);
let (src, ux, uy) = if (0..wi).contains(&sx) && (0..hi).contains(&sy) {
(a, sx, sy)
} else {
(b, sx.rem_euclid(wi), sy.rem_euclid(hi))
};
let di = ((y * wi + x) * 4) as usize;
let si = ((uy * wi + ux) * 4) as usize;
dst[di..di + 4].copy_from_slice(&src[si..si + 4]);
}
}
}
fn dissolve(a: &[u8], b: &[u8], w: u32, h: u32, dst: &mut Vec<u8>, p: f32, field: Option<&[f32]>) {
dst.resize(a.len(), 0);
let field = field.filter(|f| f.len() == (w as usize) * (h as usize));
for y in 0..h {
for x in 0..w {
let n = (y * w + x) as usize;
let frand = match field {
Some(f) => f[n],
None => xfade_frand(x, y),
};
let i = n * 4;
let src = if frand < p { b } else { a };
dst[i..i + 4].copy_from_slice(&src[i..i + 4]);
}
}
}
fn dip(a: &[u8], b: &[u8], color: [u8; 3], dst: &mut Vec<u8>, p: f32) {
const PHASE: f32 = 0.2;
dst.resize(a.len(), 0);
let bg = [
expand_luma(color[0]),
expand_luma(color[1]),
expand_luma(color[2]),
255.0,
];
let g = 1.0 - p;
let s1 = smoothstep(1.0 - PHASE, 1.0, g);
let s2 = smoothstep(PHASE, 1.0, g);
for (i, out) in dst.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let j = i * 4;
for c in 0..4 {
let av = f32::from(a[j + c]);
let bv = f32::from(b[j + c]);
let out_a = av * s1 + bg[c] * (1.0 - s1);
let out_b = bg[c] * s2 + bv * (1.0 - s2);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
{
out[c] = (out_a * g + out_b * (1.0 - g)).clamp(0.0, 255.0) as u8;
}
}
}
}
fn expand_luma(level: u8) -> f32 {
(f32::from(level) - 16.0) * 255.0 / 219.0
}
fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
pub(super) fn blend_rgba(a: &[u8], b: &[u8], alpha: f32, dst: &mut Vec<u8>) {
if a.len() != b.len() {
dst.resize(a.len(), 0);
dst.copy_from_slice(a);
return;
}
dst.resize(a.len(), 0);
let inv = 1.0_f32 - alpha;
for ((d, av), bv) in dst.iter_mut().zip(a.iter()).zip(b.iter()) {
*d = (f32::from(*av) * inv + f32::from(*bv) * alpha) as u8;
}
}
#[cfg(test)]
mod tests {
use super::*;
use ff_filter::{xfade_frand, xfade_frand_field};
#[test]
fn blend_rgba_at_zero_alpha_should_return_a() {
let a = vec![200u8, 100, 50, 255];
let b = vec![0u8, 0, 0, 255];
let mut dst = Vec::new();
blend_rgba(&a, &b, 0.0, &mut dst);
assert_eq!(dst, a);
}
#[test]
fn blend_rgba_at_full_alpha_should_return_b() {
let a = vec![0u8, 0, 0, 255];
let b = vec![200u8, 100, 50, 255];
let mut dst = Vec::new();
blend_rgba(&a, &b, 1.0, &mut dst);
assert_eq!(dst, b);
}
#[test]
fn blend_rgba_at_half_alpha_should_average() {
let a = vec![100u8, 200, 0, 255];
let b = vec![200u8, 0, 100, 255];
let mut dst = Vec::new();
blend_rgba(&a, &b, 0.5, &mut dst);
assert_eq!(dst[0], 150);
assert_eq!(dst[1], 100);
}
#[test]
fn blend_rgba_mismatched_lengths_should_copy_a() {
let a = vec![1u8, 2, 3, 4];
let b = vec![5u8, 6];
let mut dst = Vec::new();
blend_rgba(&a, &b, 0.5, &mut dst);
assert_eq!(dst, a);
}
fn frame(color: [u8; 4]) -> Vec<u8> {
color.repeat(4)
}
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
#[test]
fn apply_xfade_fade_should_match_linear_blend() {
let (a, b) = (frame(RED), frame(BLUE));
let (mut x, mut y) = (Vec::new(), Vec::new());
apply_xfade(XfadeTransition::Fade, &a, &b, 0.5, (4, 1), None, &mut x);
blend_rgba(&a, &b, 0.5, &mut y);
assert_eq!(x, y, "Fade == linear blend");
}
#[test]
fn apply_xfade_wiperight_half_should_fill_b_up_to_ffmpegs_integer_column() {
let (a, b) = (frame(RED), frame(BLUE));
let mut dst = Vec::new();
apply_xfade(
XfadeTransition::WipeRight,
&a,
&b,
0.5,
(4, 1),
None,
&mut dst,
);
assert_eq!(&dst[0..4], &BLUE, "col 0 = B");
assert_eq!(&dst[4..8], &BLUE, "col 1 = B");
assert_eq!(&dst[8..12], &BLUE, "col 2 = B (x <= z, inclusive)");
assert_eq!(&dst[12..16], &RED, "col 3 = A");
}
#[test]
fn apply_xfade_wipeleft_half_should_fill_b_past_ffmpegs_integer_column() {
let (a, b) = (frame(RED), frame(BLUE));
let mut dst = Vec::new();
apply_xfade(
XfadeTransition::WipeLeft,
&a,
&b,
0.5,
(4, 1),
None,
&mut dst,
);
assert_eq!(&dst[0..4], &RED, "col 0 = A");
assert_eq!(&dst[4..8], &RED, "col 1 = A");
assert_eq!(&dst[8..12], &RED, "col 2 = A (x > z, exclusive)");
assert_eq!(&dst[12..16], &BLUE, "col 3 = B");
}
#[test]
fn apply_xfade_boundaries_should_be_all_a_or_all_b() {
let (a, b) = (frame(RED), frame(BLUE));
for kind in [
XfadeTransition::SlideLeft,
XfadeTransition::SlideRight,
XfadeTransition::SlideUp,
XfadeTransition::SlideDown,
XfadeTransition::Dissolve,
] {
let mut dst = Vec::new();
apply_xfade(kind, &a, &b, 0.0, (4, 1), None, &mut dst);
assert_eq!(dst, a, "{kind:?} at progress 0 = all A");
apply_xfade(kind, &a, &b, 1.0, (4, 1), None, &mut dst);
assert_eq!(dst, b, "{kind:?} at progress 1 = all B");
}
}
#[test]
fn apply_xfade_wipe_endpoints_should_keep_ffmpegs_edge_column() {
let (a, b) = (frame(RED), frame(BLUE));
let mut dst = Vec::new();
apply_xfade(
XfadeTransition::WipeRight,
&a,
&b,
0.0,
(4, 1),
None,
&mut dst,
);
assert_eq!(&dst[0..4], &BLUE, "WipeRight at 0 keeps column 0 on B");
assert_eq!(&dst[4..8], &RED, "the rest is still A");
apply_xfade(
XfadeTransition::WipeLeft,
&a,
&b,
1.0,
(4, 1),
None,
&mut dst,
);
assert_eq!(&dst[0..4], &RED, "WipeLeft at 1 keeps column 0 on A");
assert_eq!(&dst[4..8], &BLUE, "the rest has flipped to B");
}
fn tagged(w: u32, h: u32, base_b: u8) -> Vec<u8> {
let mut v = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
v.extend_from_slice(&[x as u8, y as u8, base_b, 255]);
}
}
v
}
#[test]
fn apply_xfade_wipedown_half_should_fill_b_from_the_top_rows() {
let (a, b) = (tagged(2, 4, 0), tagged(2, 4, 99));
let mut dst = Vec::new();
apply_xfade(
XfadeTransition::WipeDown,
&a,
&b,
0.5,
(2, 4),
None,
&mut dst,
);
let px = |x: u32, y: u32| {
let i = ((y * 2 + x) * 4) as usize;
dst[i + 2] };
assert_eq!(px(0, 0), 99, "row 0 = B");
assert_eq!(px(1, 1), 99, "row 1 = B");
assert_eq!(px(0, 2), 99, "row 2 = B (y <= z, inclusive)");
assert_eq!(px(1, 3), 0, "row 3 = A");
}
#[test]
fn apply_xfade_slideleft_mid_should_shift_a_left_and_slide_b_in_from_the_right() {
let (a, b) = (tagged(4, 1, 0), tagged(4, 1, 99));
let mut dst = Vec::new();
apply_xfade(
XfadeTransition::SlideLeft,
&a,
&b,
0.5,
(4, 1),
None,
&mut dst,
);
let src = |x: usize| dst[x * 4 + 2]; let col = |x: usize| dst[x * 4]; assert_eq!(src(0), 0, "col 0 from A");
assert_eq!(col(0), 2, "col 0 = A[2] (shifted left by 2)");
assert_eq!(src(1), 0, "col 1 from A");
assert_eq!(col(1), 3, "col 1 = A[3]");
assert_eq!(src(2), 99, "col 2 from B (slid in)");
assert_eq!(src(3), 99, "col 3 from B (slid in)");
}
#[test]
fn apply_xfade_dissolve_mid_should_mix_a_and_b() {
let (a, b) = (tagged(16, 16, 0), tagged(16, 16, 99));
let mut dst = Vec::new();
apply_xfade(
XfadeTransition::Dissolve,
&a,
&b,
0.5,
(16, 16),
None,
&mut dst,
);
let has_a = dst.chunks_exact(4).any(|p| p[2] == 0);
let has_b = dst.chunks_exact(4).any(|p| p[2] == 99);
assert!(has_a && has_b, "mid-progress dissolve mixes both A and B");
}
#[test]
fn apply_xfade_dissolve_should_follow_ffmpegs_own_noise() {
let (w, h) = (16u32, 16u32);
let n = (w * h) as usize;
let a: Vec<u8> = [0u8, 0, 0, 255].repeat(n);
let b: Vec<u8> = [255u8, 255, 255, 255].repeat(n);
let mut dst = Vec::new();
let p = 0.5;
apply_xfade(XfadeTransition::Dissolve, &a, &b, p, (w, h), None, &mut dst);
for y in 0..h {
for x in 0..w {
let i = ((y * w + x) * 4) as usize;
let want = if xfade_frand(x, y) < p { 255 } else { 0 };
assert_eq!(dst[i], want, "pixel ({x}, {y}) must follow xfade_frand");
}
}
}
#[test]
fn apply_xfade_dissolve_with_a_cached_field_should_match_the_uncached_path() {
const W: u32 = 7;
const H: u32 = 5;
let a = tagged(W, H, 0);
let b = tagged(W, H, 128);
let field = xfade_frand_field(W, H);
for p in [0.0f32, 0.25, 0.5, 0.75, 1.0] {
let (mut cached, mut uncached) = (Vec::new(), Vec::new());
apply_xfade(
XfadeTransition::Dissolve,
&a,
&b,
p,
(W, H),
None,
&mut uncached,
);
apply_xfade(
XfadeTransition::Dissolve,
&a,
&b,
p,
(W, H),
Some(&field),
&mut cached,
);
assert_eq!(
cached, uncached,
"the cached field must select byte-identically at progress {p}"
);
}
}
#[test]
fn apply_xfade_dissolve_should_ignore_a_field_of_the_wrong_size() {
const W: u32 = 7;
const H: u32 = 5;
let a = tagged(W, H, 0);
let b = tagged(W, H, 128);
let stale = xfade_frand_field(W + 1, H + 1);
let (mut got, mut want) = (Vec::new(), Vec::new());
apply_xfade(
XfadeTransition::Dissolve,
&a,
&b,
0.5,
(W, H),
None,
&mut want,
);
apply_xfade(
XfadeTransition::Dissolve,
&a,
&b,
0.5,
(W, H),
Some(&stale),
&mut got,
);
assert_eq!(
got, want,
"a field sized for another frame must be refused, not indexed"
);
}
#[test]
fn apply_xfade_dip_should_hold_the_colour_through_the_middle() {
let a = frame(RED);
let b = frame(BLUE);
let mut dst = Vec::new();
let mut darkest = (u8::MAX, 0u32);
for i in 1..=9u32 {
let p = i as f32 / 10.0;
apply_xfade(
XfadeTransition::FadeBlack,
&a,
&b,
p,
(4, 1),
None,
&mut dst,
);
let luma = dst[0].max(dst[1]).max(dst[2]);
if luma < darkest.0 {
darkest = (luma, i);
}
}
assert!(
darkest.1 <= 3,
"the dip must bottom out in its first phase, got progress 0.{}",
darkest.1
);
}
#[test]
fn apply_xfade_deferred_kind_should_fall_back_to_fade() {
let (a, b) = (frame(RED), frame(BLUE));
let (mut x, mut y) = (Vec::new(), Vec::new());
apply_xfade(XfadeTransition::Pixelize, &a, &b, 0.3, (4, 1), None, &mut x);
blend_rgba(&a, &b, 0.3, &mut y);
assert_eq!(x, y, "deferred kinds render as the linear fade");
}
#[test]
fn apply_xfade_mismatched_buffers_should_fall_back_to_linear() {
let a = frame(RED);
let b = vec![9u8, 9];
let mut dst = Vec::new();
apply_xfade(
XfadeTransition::WipeRight,
&a,
&b,
0.5,
(4, 1),
None,
&mut dst,
);
assert_eq!(dst, a, "mismatched → linear fallback copies A");
}
}