G1: dither the final 8-bit conversion so gradients don't band (#2022)

Exports converted the float frame to 8 bits by plain rounding, so a smooth
gradient rendered at 16 or 32 bpc came out as flat bands, the same as at
8 bpc: raising the depth changed nothing visible. After Effects dithers
colours converted to 8 bpc for render and export; do the same.

- raster: dither_u8 and Image::to_rgba8_dithered / to_rgba8_over_dithered
  round each channel up or down against a hash of the pixel position with
  a fixed seed (deterministic, no clock or global RNG). A value already on
  an 8-bit code, as everything rendered at 8 bpc is, is unchanged.
- export: Cx::pixels dithers the colour for every 8-bit output (stills,
  H.264, ProRes, HEVC, AV1, VP9); GIF keeps plain rounding (palette).
- export: the Render Settings' Color Depth now overrides the project's
  depth for the whole render instead of only quantising the finished
  frame, so a 16 bpc render of an 8 bpc project keeps its precision.

Tests: crates/export/tests/it/banding.rs (ramp export at 8/16 bpc and with
Color Depth overrides), raster unit tests for dither_u8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYXhNN7W7nB6sKjwwU7CDM
This commit is contained in:
Claude
2026-10-27 15:38:08 -05:00
parent dd12ed8f9c
commit fa14090e72
5 changed files with 261 additions and 31 deletions
+1 -1
View File
@@ -375,7 +375,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_
let end = (i + batch).min(st.total);
let frames: Vec<gif::Frame<'static>> = job
.frames(comp, (i..end).collect(), |_, img| {
let mut px = job.pixels(&img, comp, job.output.channels, w, h);
let mut px = job.pixels_with(&img, comp, job.output.channels, w, h, false);
gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10)
})
.await;
+30 -23
View File
@@ -4,9 +4,7 @@
use std::sync::Mutex;
use effectcraft_project::render_queue::{
AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path,
};
use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path};
use effectcraft_project::{Comp, Project};
use effectcraft_raster::Image;
use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer};
@@ -166,6 +164,9 @@ impl<'a> Cx<'a> {
pub fn new(job: &'a Job<'a>) -> Cx<'a> {
let mut project = job.project.clone();
apply_overrides(&mut project, job);
// Render Settings ▸ Color Depth overrides the project's depth for the whole render (every
// layer and effect quantises at it), not just the finished frame (#2022).
project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth);
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
let cache = LayerCache::default();
// With deferred readbacks, buffers computed from a missed pass's placeholders are not kept.
@@ -289,14 +290,6 @@ impl<'a> Cx<'a> {
if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) }
}
};
let mut img = img;
// Color Depth: renders run at the project's depth; another depth quantises the frame.
let depth = self.job.settings.color_depth;
if depth != ColorDepth::Current
&& let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels()
{
img.quantize(levels);
}
let img = self.crop_resize(img);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64()));
@@ -325,12 +318,21 @@ impl<'a> Cx<'a> {
/// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over
/// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte.
/// The colour is dithered on its way to 8 bits, as After Effects dithers colours converted to
/// 8 bpc for rendering and export, so gradients rendered at 16 or 32 bpc don't band (#2022).
pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
self.pixels_with(img, comp, channels, w, h, true)
}
/// [`Cx::pixels`], dithering the colour or not (a GIF palette gains nothing from dither noise).
pub fn pixels_with(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, dither: bool) -> Vec<u8> {
let px = match channels {
Channels::Rgb if dither => img.to_rgba8_over_dithered(comp.background),
Channels::Rgb => img.to_rgba8_over(comp.background),
Channels::Rgba => match self.job.output.alpha_mode {
AlphaMode::Straight => img.to_rgba8(),
AlphaMode::Premultiplied => premultiplied8(img),
Channels::Rgba => match (self.job.output.alpha_mode, dither) {
(AlphaMode::Straight, true) => img.to_rgba8_dithered(),
(AlphaMode::Straight, false) => img.to_rgba8(),
(AlphaMode::Premultiplied, _) => premultiplied8(img, dither),
},
Channels::Alpha => img
.data
@@ -491,15 +493,20 @@ fn bicubic(src: &Image, w: u32, h: u32) -> Image {
out
}
/// Premultiplied 8-bit RGBA (colour matted with black).
fn premultiplied8(img: &Image) -> Vec<u8> {
img.data
.iter()
.flat_map(|p| {
let a = p[3].clamp(0.0, 1.0);
[0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8])
})
.collect()
/// Premultiplied 8-bit RGBA (colour matted with black), the colour dithered when `dither`.
fn premultiplied8(img: &Image, dither: bool) -> Vec<u8> {
let w = img.width.max(1) as usize;
let mut out = Vec::with_capacity(img.data.len() * 4);
for (i, p) in img.data.iter().enumerate() {
let a = p[3].clamp(0.0, 1.0);
let (x, y) = ((i % w) as u32, (i / w) as u32);
for c in 0..3 {
let v = p[c].clamp(0.0, a);
out.push(if dither { effectcraft_raster::dither_u8(v, x, y) } else { (v * 255.0 + 0.5) as u8 });
}
out.push((a * 255.0 + 0.5) as u8);
}
out
}
#[cfg(test)]