Files
effectcraft/crates/raster/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 bfa21e3222 M4.3: frame blending
Frame Mix and Pixel Motion for footage and precomp layers when the layer's
Frame Blending switch and the comp's Enable Frame Blending are on and the
source time falls between source frames (rate conform, time stretch, time
remap). Exact frame positions from tick arithmetic. Pixel Motion is a
hierarchical block-matching optical flow (SAD search, sub-pixel parabola
fit, vector median) with a bidirectional warp-blend (raster::flow). Blended
footage frames are cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:05:52 -04:00

351 lines
12 KiB
Rust

//! Raster images for the compositor.
//!
//! [`Image`] stores premultiplied RGBA in `f32` (values may exceed 1.0 in 32 bpc projects). Every
//! heavy operation is row-parallel with rayon.
pub mod blur;
pub mod flow;
pub mod warp;
use effectcraft_color::{BlendMode, blend_pixel};
use rayon::prelude::*;
pub use blur::{box_blur, directional_blur, gaussian_blur, radial_blur};
pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample};
pub type Px = [f32; 4];
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Image {
pub width: u32,
pub height: u32,
pub data: Vec<Px>,
}
impl Image {
pub fn new(width: u32, height: u32) -> Image {
Image { width, height, data: vec![[0.0; 4]; width as usize * height as usize] }
}
pub fn filled(width: u32, height: u32, px: Px) -> Image {
Image { width, height, data: vec![px; width as usize * height as usize] }
}
pub fn is_empty(&self) -> bool {
self.width == 0 || self.height == 0
}
#[inline]
pub fn idx(&self, x: u32, y: u32) -> usize {
y as usize * self.width as usize + x as usize
}
#[inline]
pub fn get(&self, x: i64, y: i64) -> Px {
if x < 0 || y < 0 || x >= self.width as i64 || y >= self.height as i64 { [0.0; 4] } else { self.data[y as usize * self.width as usize + x as usize] }
}
#[inline]
pub fn get_clamped(&self, x: i64, y: i64) -> Px {
let x = x.clamp(0, self.width as i64 - 1);
let y = y.clamp(0, self.height as i64 - 1);
self.data[y as usize * self.width as usize + x as usize]
}
#[inline]
pub fn set(&mut self, x: u32, y: u32, px: Px) {
let i = self.idx(x, y);
self.data[i] = px;
}
pub fn rows_mut(&mut self) -> impl IndexedParallelIterator<Item = (usize, &mut [Px])> {
let w = self.width.max(1) as usize;
self.data.par_chunks_mut(w).enumerate()
}
/// Bilinear sample at continuous pixel coordinates (pixel centres at +0.5); transparent outside.
#[inline]
pub fn sample_bilinear(&self, x: f64, y: f64) -> Px {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let tx = (fx - x0) as f32;
let ty = (fy - y0) as f32;
let (x0, y0) = (x0 as i64, y0 as i64);
let a = self.get(x0, y0);
let b = self.get(x0 + 1, y0);
let c = self.get(x0, y0 + 1);
let d = self.get(x0 + 1, y0 + 1);
let mut o = [0.0; 4];
for i in 0..4 {
let top = a[i] + (b[i] - a[i]) * tx;
let bot = c[i] + (d[i] - c[i]) * tx;
o[i] = top + (bot - top) * ty;
}
o
}
/// Bilinear with edge pixels repeated outside the image.
#[inline]
pub fn sample_bilinear_clamped(&self, x: f64, y: f64) -> Px {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let tx = (fx - x0) as f32;
let ty = (fy - y0) as f32;
let (x0, y0) = (x0 as i64, y0 as i64);
let a = self.get_clamped(x0, y0);
let b = self.get_clamped(x0 + 1, y0);
let c = self.get_clamped(x0, y0 + 1);
let d = self.get_clamped(x0 + 1, y0 + 1);
let mut o = [0.0; 4];
for i in 0..4 {
let top = a[i] + (b[i] - a[i]) * tx;
let bot = c[i] + (d[i] - c[i]) * tx;
o[i] = top + (bot - top) * ty;
}
o
}
/// Catmull-Rom bicubic sample; transparent outside.
pub fn sample_bicubic(&self, x: f64, y: f64) -> Px {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = fx.floor();
let y0 = fy.floor();
let tx = fx - x0;
let ty = fy - y0;
let w = |t: f64| -> [f32; 4] {
let t2 = t * t;
let t3 = t2 * t;
[(-0.5 * t3 + t2 - 0.5 * t) as f32, (1.5 * t3 - 2.5 * t2 + 1.0) as f32, (-1.5 * t3 + 2.0 * t2 + 0.5 * t) as f32, (0.5 * t3 - 0.5 * t2) as f32]
};
let wx = w(tx);
let wy = w(ty);
let mut o = [0.0f32; 4];
for j in 0..4 {
for i in 0..4 {
let p = self.get(x0 as i64 - 1 + i as i64, y0 as i64 - 1 + j as i64);
let k = wx[i] * wy[j];
for c in 0..4 {
o[c] += p[c] * k;
}
}
}
// Keep premultiplied invariants after ringing.
o[3] = o[3].max(0.0);
for c in 0..3 {
o[c] = o[c].max(0.0);
}
o
}
/// Copy with `pad` transparent pixels on every side.
pub fn padded(&self, pad: u32) -> Image {
let mut out = Image::new(self.width + 2 * pad, self.height + 2 * pad);
let ow = out.width as usize;
for y in 0..self.height as usize {
let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize];
let o = (y + pad as usize) * ow + pad as usize;
out.data[o..o + self.width as usize].copy_from_slice(src);
}
out
}
/// Sub-rectangle copy (clipped).
pub fn crop(&self, x: i64, y: i64, w: u32, h: u32) -> Image {
let mut out = Image::new(w, h);
out.rows_mut().for_each(|(oy, row)| {
for (ox, px) in row.iter_mut().enumerate() {
*px = self.get(x + ox as i64, y + oy as i64);
}
});
out
}
/// Multiply every pixel (premultiplied) by `k`.
pub fn scale_alpha(&mut self, k: f32) {
if (k - 1.0).abs() < 1e-7 {
return;
}
self.data.par_iter_mut().for_each(|p| {
for c in p.iter_mut() {
*c *= k;
}
});
}
/// Multiply each pixel by a coverage value from `mask` (same size).
pub fn mul_mask(&mut self, mask: &[f32]) {
self.data.par_iter_mut().zip(mask.par_iter()).for_each(|(p, &m)| {
for c in p.iter_mut() {
*c *= m;
}
});
}
/// Clamp to [0, 1] (8/16 bpc working space).
pub fn clamp01(&mut self) {
self.data.par_iter_mut().for_each(|p| {
for c in p.iter_mut() {
*c = c.clamp(0.0, 1.0);
}
for c in 0..3 {
p[c] = p[c].min(p[3]);
}
});
}
/// Quantise to `levels` per channel (simulates 8/16 bpc output).
pub fn quantize(&mut self, levels: f32) {
self.data.par_iter_mut().for_each(|p| {
for c in p.iter_mut() {
*c = (*c * levels).round() / levels;
}
});
}
/// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored).
pub fn to_rgba8(&self) -> Vec<u8> {
let mut out = vec![0u8; self.data.len() * 4];
out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| {
let a = p[3].clamp(0.0, 1.0);
let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 };
for c in 0..3 {
o[c] = ((p[c] * inv).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
o[3] = (a * 255.0 + 0.5) as u8;
});
out
}
/// Composite over an opaque background colour and return opaque 8-bit RGBA.
pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec<u8> {
let mut out = vec![0u8; self.data.len() * 4];
out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| {
let k = 1.0 - p[3].clamp(0.0, 1.0);
for c in 0..3 {
o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
o[3] = 255;
});
out
}
/// From straight-alpha 8-bit RGBA.
pub fn from_rgba8(width: u32, height: u32, rgba: &[u8]) -> Image {
let mut img = Image::new(width, height);
img.data.par_iter_mut().zip(rgba.par_chunks(4)).for_each(|(p, s)| {
let a = s[3] as f32 / 255.0;
*p = [s[0] as f32 / 255.0 * a, s[1] as f32 / 255.0 * a, s[2] as f32 / 255.0 * a, a];
});
img
}
/// Straight-alpha colour at a pixel.
pub fn straight(&self, x: i64, y: i64) -> Px {
let p = self.get(x, y);
if p[3] > 0.0 { [p[0] / p[3], p[1] / p[3], p[2] / p[3], p[3]] } else { [0.0; 4] }
}
/// Map every pixel through `f` on straight colour (keeps alpha).
pub fn map_straight(&mut self, f: impl Fn([f32; 3]) -> [f32; 3] + Sync) {
self.data.par_iter_mut().for_each(|p| {
let a = p[3];
if a <= 0.0 {
return;
}
let c = f([p[0] / a, p[1] / a, p[2] / a]);
*p = [c[0] * a, c[1] * a, c[2] * a, a];
});
}
/// Blend `src` (same size) onto self with a mode and opacity.
pub fn blend_from(&mut self, src: &Image, mode: BlendMode, opacity: f32, seed: u32) {
let w = self.width as usize;
self.data.par_chunks_mut(w.max(1)).zip(src.data.par_chunks(w.max(1))).enumerate().for_each(|(y, (d, s))| {
for x in 0..d.len() {
let mut sp = s[x];
if sp[3] <= 0.0 && !mode.is_stencil() {
continue;
}
for c in sp.iter_mut() {
*c *= opacity;
}
let n = if matches!(mode, BlendMode::Dissolve | BlendMode::DancingDissolve) { hash_noise(x as u32, y as u32, seed) } else { 0.5 };
d[x] = blend_pixel(mode, d[x], sp, n);
}
});
}
/// Bounding box of non-transparent pixels (x0, y0, x1, y1), None if empty.
pub fn alpha_bounds(&self) -> Option<(u32, u32, u32, u32)> {
let w = self.width as usize;
let mut b: Option<(u32, u32, u32, u32)> = None;
for (y, row) in self.data.chunks(w.max(1)).enumerate() {
if let Some(first) = row.iter().position(|p| p[3] > 1e-6) {
let last = row.iter().rposition(|p| p[3] > 1e-6).unwrap_or(first);
let (y, f, l) = (y as u32, first as u32, last as u32 + 1);
b = Some(match b {
None => (f, y, l, y + 1),
Some((x0, y0, x1, _)) => (x0.min(f), y0, x1.max(l), y + 1),
});
}
}
b
}
}
/// Deterministic per-pixel noise in 0..1.
#[inline]
pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 {
let mut h = x.wrapping_mul(0x8da6_b343) ^ y.wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
h ^= h >> 13;
h = h.wrapping_mul(0x5bd1_e995);
h ^= h >> 15;
(h & 0x00ff_ffff) as f32 / 16_777_216.0
}
/// A single-channel coverage mask.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Mask {
pub width: u32,
pub height: u32,
pub data: Vec<f32>,
}
impl Mask {
pub fn new(width: u32, height: u32, v: f32) -> Mask {
Mask { width, height, data: vec![v; width as usize * height as usize] }
}
pub fn to_image(&self) -> Image {
Image { width: self.width, height: self.height, data: self.data.iter().map(|&a| [a, a, a, a]).collect() }
}
pub fn from_alpha(img: &Image) -> Mask {
Mask { width: img.width, height: img.height, data: img.data.iter().map(|p| p[3]).collect() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgba8_roundtrip() {
let src: Vec<u8> = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4];
let img = Image::from_rgba8(2, 2, &src);
let back = img.to_rgba8();
for (a, b) in src.chunks(4).zip(back.chunks(4)) {
if a[3] >= 128 {
assert!(a.iter().zip(b).all(|(x, y)| (*x as i32 - *y as i32).abs() <= 1), "{a:?} {b:?}");
} else {
assert!((a[3] as i32 - b[3] as i32).abs() <= 1);
}
}
}
#[test]
fn bilinear_at_centres_is_exact() {
let mut img = Image::new(3, 3);
img.set(1, 1, [0.5, 0.25, 0.125, 1.0]);
assert_eq!(img.sample_bilinear(1.5, 1.5), [0.5, 0.25, 0.125, 1.0]);
let half = img.sample_bilinear(2.0, 1.5);
assert!((half[3] - 0.5).abs() < 1e-6);
}
#[test]
fn alpha_bounds_found() {
let mut img = Image::new(10, 10);
img.set(3, 4, [1.0; 4]);
img.set(6, 7, [1.0; 4]);
assert_eq!(img.alpha_bounds(), Some((3, 4, 7, 8)));
}
#[test]
fn noise_in_range() {
for i in 0..1000 {
let n = hash_noise(i, i * 7, 3);
assert!((0.0..1.0).contains(&n));
}
}
}