Merge main into fix/2022-render-banding (#1631)

Resolve the export tests' module list (banding and h264_gpu_yuv both stay). #1631's
yuv420_frame checked Render Settings Color Depth itself; that depth is now the project's
depth for the whole render, so the GPU YUV path is taken only for 8 bpc renders. Above
8 bpc frames take the RGBA path, which dithers, so H.264 exports don't band either.
Test: renders_above_8_bpc_skip_the_planes.
This commit is contained in:
Claude
2026-10-29 03:06:31 -05:00
293 changed files with 13431 additions and 5778 deletions
+3 -1
View File
@@ -247,7 +247,9 @@ pub fn radial_blur(img: &Image, center: (f64, f64), amount: f64, zoom: bool) ->
let (s, c) = (t * amount).to_radians().sin_cos();
(center.0 + vx * c - vy * s, center.1 + vx * s + vy * c)
};
let p = img.sample_bilinear(sx, sy);
// Samples outside the image repeat its edge: After Effects' Radial Blur keeps a
// layer's edges (cases 713, 829) instead of fading them toward transparency.
let p = img.sample_bilinear(sx.clamp(0.5, (img.width as f64 - 0.5).max(0.5)), sy.clamp(0.5, (img.height as f64 - 0.5).max(0.5)));
for ch in 0..4 {
acc[ch] += p[ch];
}
+30 -23
View File
@@ -607,38 +607,44 @@ pub struct FillInput {
pub references: Vec<(usize, Image)>,
}
/// Trace pixel (x, y) of frame `t` through `flows` (`step` = +1 forward with `fwd`, −1 backward
/// with `bwd`) to the nearest frame where it is visible: (sampled colour, frames travelled).
fn trace(
t: usize,
/// Shared frame and flow data for tracing pixels through a sequence.
struct TraceContext<'a> {
fwd: &'a [Flow],
bwd: &'a [Flow],
known: &'a (dyn Fn(usize, i64, i64) -> bool + Sync),
frames: &'a [&'a Image],
max: usize,
}
/// Starting frame, point, and direction for a flow trace.
struct TraceStart {
frame: usize,
x: f64,
y: f64,
step: i64,
fwd: &[Flow],
bwd: &[Flow],
known: &dyn Fn(usize, i64, i64) -> bool,
frames: &[&Image],
max: usize,
) -> Option<([f32; 4], usize)> {
let n = frames.len();
let (w, h) = (frames[0].width as f64, frames[0].height as f64);
let (mut px, mut py) = (x, y);
let mut k = t as i64;
for d in 1..=max {
let v = if step > 0 {
}
/// Trace a point to the nearest visible frame: (sampled colour, frames travelled).
fn trace(context: &TraceContext<'_>, start: TraceStart) -> Option<([f32; 4], usize)> {
let n = context.frames.len();
let (w, h) = (context.frames[0].width as f64, context.frames[0].height as f64);
let (mut px, mut py) = (start.x, start.y);
let mut k = start.frame as i64;
for d in 1..=context.max {
let v = if start.step > 0 {
if k as usize + 1 >= n {
return None;
}
fwd[k as usize].at(px, py)
context.fwd[k as usize].at(px, py)
} else {
if k == 0 {
return None;
}
bwd[k as usize - 1].at(px, py)
context.bwd[k as usize - 1].at(px, py)
};
px += v[0] as f64;
py += v[1] as f64;
k += step;
k += start.step;
if px < 0.0 || py < 0.0 || px >= w || py >= h {
return None;
}
@@ -646,9 +652,9 @@ fn trace(
let (x0, y0) = ((px - 0.5).floor() as i64, (py - 0.5).floor() as i64);
let ok = [(x0, y0), (x0 + 1, y0), (x0, y0 + 1), (x0 + 1, y0 + 1)]
.iter()
.all(|&(xx, yy)| known(k as usize, xx.clamp(0, w as i64 - 1), yy.clamp(0, h as i64 - 1)));
.all(|&(xx, yy)| (context.known)(k as usize, xx.clamp(0, w as i64 - 1), yy.clamp(0, h as i64 - 1)));
if ok {
return Some((frames[k as usize].sample_bilinear_clamped(px, py), d));
return Some((context.frames[k as usize].sample_bilinear_clamped(px, py), d));
}
}
None
@@ -697,6 +703,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u
// 2. Propagation (and the lighting-correction border ring).
let refs: Vec<&Image> = frames.iter().collect();
let known = |k: usize, x: i64, y: i64| !holes[k][y as usize * w + x as usize];
let trace_context = TraceContext { fwd: &fwd, bwd: &bwd, known: &known, frames: &refs, max: n };
let ring_r = 2usize;
let propagated: Vec<(Image, Vec<bool>)> = (0..n)
.into_par_iter()
@@ -708,8 +715,8 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u
}
let sample = |i: usize| -> Option<[f32; 4]> {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
let f = trace(t, x, y, 1, &fwd, &bwd, &known, &refs, n);
let b = trace(t, x, y, -1, &fwd, &bwd, &known, &refs, n);
let f = trace(&trace_context, TraceStart { frame: t, x, y, step: 1 });
let b = trace(&trace_context, TraceStart { frame: t, x, y, step: -1 });
match (f, b) {
(Some((a, da)), Some((c, dc))) => {
let (wa, wc) = (1.0 / da as f32, 1.0 / dc as f32);
+1
View File
@@ -12,6 +12,7 @@ pub mod flow;
pub mod inpaint;
pub mod scopes;
pub mod warp;
pub mod yuv;
use effectcraft_color::{BlendMode, blend_pixel};
use rayon::prelude::*;
+22 -21
View File
@@ -203,22 +203,17 @@ fn union(r: [usize; 4], q: [usize; 4]) -> [usize; 4] {
}
}
/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. Returns
/// the rectangle of the pixels it blended.
#[allow(clippy::too_many_arguments)]
fn affine_rows(
dst: &mut Image,
img: &Image,
inv: &Mat3,
/// Sampling, blending and destination bounds for an affine row operation.
struct AffineRows {
sampling: Sampling,
op: f32,
opacity: f32,
quantize: Option<f32>,
x0: usize,
x1: usize,
y0: usize,
y1: usize,
blend: impl Fn(Px, Px, u32, u32) -> Px + Sync,
) -> [usize; 4] {
bounds: [usize; 4],
}
/// Blend visible source samples and return the rectangle of changed pixels.
fn affine_rows(dst: &mut Image, img: &Image, inv: &Mat3, rows: AffineRows, blend: impl Fn(Px, Px, u32, u32) -> Px + Sync) -> [usize; 4] {
let AffineRows { sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] } = rows;
let levels = quantize.map(|l| (l, 1.0 / l));
let w = dst.width as usize;
let m = inv.0;
@@ -363,22 +358,28 @@ fn composite_warp_impl(
if let Some(k) = accumulate
&& affine
{
let r = affine_rows(dst, img, &inv, opts.sampling, 1.0, None, x0, x1, y0, y1, |d, s, _, _| {
let r = affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: 1.0, quantize: None, bounds: [x0, x1, y0, y1] }, |d, s, _, _| {
[d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k]
});
return changed(r);
}
if allow_fast && affine && !mode.is_stencil() {
let r = match mode {
BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| {
let k = 1.0 - s[3];
[s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k]
}),
BlendMode::Normal => {
affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] }, |d, s, _, _| {
let k = 1.0 - s[3];
[s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k]
})
}
BlendMode::Dissolve | BlendMode::DancingDissolve => {
let seed = opts.seed;
affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed)))
affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] }, |d, s, x, y| {
blend_pixel(mode, d, s, hash_noise(x, y, seed))
})
}
_ => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)),
_ => affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] }, |d, s, _, _| {
blend_pixel(mode, d, s, 0.5)
}),
};
return changed(r);
}
+60
View File
@@ -0,0 +1,60 @@
//! 8-bit BT.709 limited-range 4:2:0, the H.264 export's input. Shared by the export and by the
//! GPU compositor's tests, which hold its kernel to this function.
use rayon::prelude::*;
/// `v / 255` for every 8-bit `v` (the same values as dividing).
pub const UNIT8: [f32; 256] = {
let mut t = [0.0; 256];
let mut i = 0;
while i < 256 {
t[i] = i as f32 / 255.0;
i += 1;
}
t
};
/// `v.round()` for `0 <= v < 2^23`, the range the colour conversions round in: truncation and
/// the fraction are exact there, so this is `f32::round` without its libm call.
#[inline(always)]
pub fn round_pos(v: f32) -> f32 {
let t = v as u32 as f32;
if v - t >= 0.5 { t + 1.0 } else { t }
}
/// BT.709 limited-range 8-bit 4:2:0 from RGBA8 (2×2 chroma average; alpha ignored).
pub fn rgba_to_yuv420(rgba: &[u8], w: usize, h: usize, y: &mut Vec<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
y.resize(w * h, 0);
u.resize(cw * ch, 0);
v.resize(cw * ch, 0);
y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| {
let mut acc = vec![(0f32, 0f32, 0f32); cw];
for (dy, yrow) in yr.chunks_exact_mut(w).enumerate() {
let src = &rgba[(cy * 2 + dy) * w * 4..][..w * 4];
for (x, (s, yo)) in src.as_chunks::<4>().0.iter().zip(yrow.iter_mut()).enumerate() {
let (r, g, b) = (UNIT8[s[0] as usize], UNIT8[s[1] as usize], UNIT8[s[2] as usize]);
let l = 0.2126 * r + 0.7152 * g + 0.0722 * b;
*yo = round_pos(16.0 + 219.0 * l).clamp(1.0, 254.0) as u8;
let a = &mut acc[x / 2];
a.0 += (b - l) / 1.8556;
a.1 += (r - l) / 1.5748;
a.2 += 1.0;
}
}
for (cx, a) in acc.iter().enumerate() {
ur[cx] = round_pos(128.0 + 224.0 * a.0 / a.2).clamp(1.0, 254.0) as u8;
vr[cx] = round_pos(128.0 + 224.0 * a.1 / a.2).clamp(1.0, 254.0) as u8;
}
});
}
/// The H.264 export's planes of `img` over `bg` (opaque): Y, then U and V, as
/// [`crate::Image::to_rgba8_over`] + [`rgba_to_yuv420`] produce them.
pub fn yuv420_over(img: &crate::Image, bg: [f32; 3]) -> Vec<u8> {
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
rgba_to_yuv420(&img.to_rgba8_over(bg), img.width as usize, img.height as usize, &mut y, &mut u, &mut v);
y.extend_from_slice(&u);
y.extend_from_slice(&v);
y
}