collective

Browse / github.com/storytold/effectcraft / patch

export: dither 16/32 bpc renders on the way to 8 bits, and let Render Settings Color Depth set the render's depth (#2022)

Bug: Renders show banding Gradients exhibit visible banding when rendering comps out via the render queue. The sample project that is included with the application also suffers from this issue. Switching the comp to 16 bit color depth does not visibly improve quality. Tested with version 0.11.1 on Windows and VLC player to play back the renders.

Status
open
Score
0.00
Adopters
0 other operators
Author
klosixgaming-arch (imported from pull request #2534, open)
Base
dd12ed8f9c341a6081e08c8fd840aa3bb4325537
Size
+265 −32 in 5 files
Review
pending
Shared
2026-10-28
Id
e6b3d154052a798d39434d64

How it went

No other operator has reported using it yet.

Diff

raw · crates/export/src/lib.rs crates/export/src/pipeline.rs crates/export/tests/it/banding.rs crates/export/tests/it/main.rs crates/raster/src/lib.rs

diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs--- a/crates/export/src/lib.rs+++ b/crates/export/src/lib.rs@@ -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;diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs--- a/crates/export/src/pipeline.rs+++ b/crates/export/src/pipeline.rs@@ -4,10 +4,8 @@  use std::sync::Mutex; -use effectcraft_project::render_queue::{-    AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path,-};-use effectcraft_project::{Comp, Project};+use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path};+use effectcraft_project::{BitDepth, Comp, Project}; use effectcraft_raster::Image; use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer}; use effectcraft_time::Tick;@@ -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,24 @@ 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.+    /// When the render runs above 8 bpc, the colour is dithered on its way to 8 bits, as After+    /// Effects dithers colours converted down to 8 bpc for rendering and export, so gradients+    /// rendered at 16 or 32 bpc don't band (#2022). An 8 bpc render has no finer values to+    /// dither, so its output stays byte for byte what it was.     pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {+        let dither = self.project.settings.bit_depth != BitDepth::Bpc8;+        self.pixels_with(img, comp, channels, w, h, dither)+    }++    /// [`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 +496,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)]diff --git a/crates/export/tests/it/banding.rs b/crates/export/tests/it/banding.rsnew file mode 100644--- /dev/null+++ b/crates/export/tests/it/banding.rs@@ -0,0 +1,156 @@+//! Gradients exported at 16 bpc (or with a 16 bpc Color Depth override) don't band (#2022): the+//! float frame is dithered on its way to 8 bits, and the Render Settings' Color Depth reaches the+//! render itself.++use std::sync::{Arc, Mutex};++use effectcraft_color::Label;+use effectcraft_export::render_queue::*;+use effectcraft_export::{Job, JobOptions, Sink, export};+use effectcraft_keyframe::Value;+use effectcraft_project::build::{self, Ids};+use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, LayerSource, Project, Solid};+use effectcraft_render::NoFootage;+use effectcraft_time::{FrameRate, Tick};++const W: u32 = 1024;+const H: u32 = 16;+/// The ramp runs from this grey to `HI` across the comp: about 25 eight-bit codes in 1024 px,+/// so plain rounding makes bands about 40 px wide.+const LO: f64 = 0.20;+const HI: f64 = 0.30;+/// Width of the column blocks whose mean is compared with the ideal ramp.+const WINDOW: u32 = 8;++/// A grey horizontal ramp over a comp of the given project depth.+fn ramp_project(depth: BitDepth) -> (Project, ItemId) {+    let mut p = Project::default();+    p.settings.bit_depth = depth;+    let mut comp = Comp::new(W, H, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0));+    comp.background = [0.0, 0.0, 0.0];+    let sid = p.add_item("Ramp", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: W, height: H, pixel_aspect: 1.0 }));+    let mut layer = build::layer(&mut p, &comp, "Ramp", LayerSource::Solid { item: sid }, (W, H), None);+    let spec = effectcraft_effects::find("ec.generate.gradientramp").expect("gradient ramp effect");+    let mut next = p.next_id;+    let mut group = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);+    p.next_id = next;+    for (k, v) in [+        ("start", Value::Vec2([0.0, 0.0])),+        ("end", Value::Vec2([W as f64, 0.0])),+        ("startColor", Value::Color([LO, LO, LO, 1.0])),+        ("endColor", Value::Color([HI, HI, HI, 1.0])),+    ] {+        group.get_mut(k).expect("ramp parameter").value = v;+    }+    layer.props.sub_mut("effects").expect("effects group").children.push(group.into());+    comp.layers.push(layer);+    let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));+    (p, cid)+}++/// Export frame 0 as PNG with the Render Settings' Color Depth `depth` and return the grey of+/// every row.+fn export_rows(p: &Project, cid: ItemId, depth: ColorDepth) -> Vec<Vec<u8>> {+    let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();+    let f = files.clone();+    let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));+    let settings =+        RenderSettings { time_span: TimeSpan::Custom { start: Tick::ZERO, end: Tick::from_seconds_f64(0.04) }, color_depth: depth, ..Default::default() };+    let om = OutputModule::for_format(OutputFormat::PngSequence);+    let job = Job {+        project: p,+        footage: &NoFootage,+        expr: None,+        accel: None,+        comp: cid,+        settings: &settings,+        output: &om,+        path: "/banding/f_[#####].png",+        sink: Some(&*sink),+        options: JobOptions::default(),+        nested_switches: true,+    };+    export(&job, &mut |_| true).expect("export");+    let files = files.lock().unwrap();+    let (_, png) = files.iter().find(|(path, _)| path.ends_with(".png")).expect("a PNG");+    let img = image::load_from_memory(png).expect("png").to_rgba8();+    (0..H).map(|y| (0..W).map(|x| img.get_pixel(x, y).0[1]).collect()).collect()+}++/// The ideal ramp at column `x`, in 8-bit codes.+fn ideal(x: f64) -> f64 {+    255.0 * (LO + (HI - LO) * (x + 0.5) / W as f64)+}++/// Longest run of equal neighbours in the row: a band's width.+fn longest_run(row: &[u8]) -> usize {+    let (mut best, mut run) = (1, 1);+    for w in row.windows(2) {+        run = if w[0] == w[1] { run + 1 } else { 1 };+        best = best.max(run);+    }+    best+}++/// Horizontal neighbours with different values, over all rows: ~25 per row when every band is a+/// flat step, hundreds when the steps are dithered.+fn transitions(rows: &[Vec<u8>]) -> usize {+    rows.iter().map(|r| r.windows(2).filter(|w| w[0] != w[1]).count()).sum()+}++/// Largest difference, in 8-bit codes, between the mean of a `WINDOW`-column block over all rows+/// and the ideal ramp's mean over it. Plain rounding leaves up to ~0.4 codes of it beside a step+/// edge; dither averages it away (its noise on a mean of `WINDOW * H` samples is under 0.05).+fn worst_window_error(rows: &[Vec<u8>]) -> f64 {+    (0..W / WINDOW)+        .map(|i| {+            let (x0, x1) = ((i * WINDOW) as usize, ((i + 1) * WINDOW) as usize);+            let sum: f64 = rows.iter().flat_map(|r| &r[x0..x1]).map(|&v| v as f64).sum();+            let mean = sum / (rows.len() * (x1 - x0)) as f64;+            (mean - ideal((x0 + x1) as f64 / 2.0 - 1.0)).abs()+        })+        .fold(0.0, f64::max)+}++#[test]+fn a_16_bpc_gradient_export_is_dithered() {+    let (p, cid) = ramp_project(BitDepth::Bpc16);+    let rows = export_rows(&p, cid, ColorDepth::Current);+    let (steps, err) = (transitions(&rows), worst_window_error(&rows));+    eprintln!("16 bpc project: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes");+    // Undithered there are ~25 flat steps per row (400 transitions) and the block means are off+    // by up to ~0.4 codes.+    assert!(steps > 2000, "the steps survive: {steps} transitions");+    assert!(err < 0.25, "the local mean is off by {err} codes");+    // Dither never moves a value a whole code from the ideal ramp.+    for row in &rows {+        for (x, &v) in row.iter().enumerate() {+            let want = ideal(x as f64);+            assert!((v as f64 - want).abs() < 1.05, "x {x}: {v} vs {want}");+        }+    }+}++#[test]+fn the_export_is_the_same_every_time() {+    let (p, cid) = ramp_project(BitDepth::Bpc16);+    assert_eq!(export_rows(&p, cid, ColorDepth::Current), export_rows(&p, cid, ColorDepth::Current));+}++#[test]+fn the_render_color_depth_reaches_the_render() {+    // An 8 bpc project renders every layer at 8 bits: flat steps, nothing for the output to dither.+    let (p, cid) = ramp_project(BitDepth::Bpc8);+    let banded = export_rows(&p, cid, ColorDepth::Current);+    assert!(longest_run(&banded[0]) >= 30, "an 8 bpc render is quantised before export");+    assert!(transitions(&banded) < 16 * 30);+    // The Render Settings' 16 bpc depth overrides the project's: the render keeps its precision+    // until the final conversion, which dithers.+    let deep = export_rows(&p, cid, ColorDepth::Bpc16);+    let (steps, err) = (transitions(&deep), worst_window_error(&deep));+    eprintln!("8 bpc project, 16 bpc render: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes");+    assert!(steps > 2000 && err < 0.25, "a 16 bpc render still bands: {steps} transitions, error {err}");+    // And 8 bpc on a 16 bpc project renders at 8 bits.+    let (p16, cid16) = ramp_project(BitDepth::Bpc16);+    assert_eq!(export_rows(&p16, cid16, ColorDepth::Bpc8), banded);+}diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs--- a/crates/export/tests/it/main.rs+++ b/crates/export/tests/it/main.rs@@ -1,6 +1,7 @@ //! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).  mod audio_sample_positions;+mod banding; mod hevc_av1_roundtrip; #[cfg(not(target_arch = "wasm32"))] mod mov_audio_sample_positions;diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs--- a/crates/raster/src/lib.rs+++ b/crates/raster/src/lib.rs@@ -221,7 +221,7 @@ impl Image {     }     /// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored).     pub fn to_rgba8(&self) -> Vec<u8> {-        self.map_rgba8(|o, p| {+        self.map_rgba8(|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 {@@ -230,9 +230,20 @@ impl Image {             o[3] = (a * 255.0 + 0.5) as u8;         })     }+    /// [`Image::to_rgba8`] with the colour dithered ([`dither_u8`]); alpha is rounded.+    pub fn to_rgba8_dithered(&self) -> Vec<u8> {+        self.map_rgba8(|o, p, x, y| {+            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] = dither_u8(p[c] * inv, x, y);+            }+            o[3] = (a * 255.0 + 0.5) as u8;+        })+    }     /// Composite over an opaque background colour and return opaque 8-bit RGBA.     pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec<u8> {-        self.map_rgba8(|o, p| {+        self.map_rgba8(|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;@@ -240,13 +251,23 @@ impl Image {             o[3] = 255;         })     }-    /// 8-bit RGBA with each pixel written by `f`, row-parallel.-    fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec<u8> {+    /// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]).+    pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec<u8> {+        self.map_rgba8(|o, p, x, y| {+            let k = 1.0 - p[3].clamp(0.0, 1.0);+            for c in 0..3 {+                o[c] = dither_u8(p[c] + bg[c] * k, x, y);+            }+            o[3] = 255;+        })+    }+    /// 8-bit RGBA with each pixel written by `f` (given its x and y), row-parallel.+    fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px, u32, u32) + Sync) -> Vec<u8> {         let mut out = vec![0u8; self.data.len() * 4];         let w = self.width.max(1) as usize;-        out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| {-            for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) {-                f(o, p);+        out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).enumerate().for_each(|(y, (o, row))| {+            for (x, (o, p)) in o.as_chunks_mut::<4>().0.iter_mut().zip(row).enumerate() {+                f(o, p, x as u32, y as u32);             }         });         out@@ -321,6 +342,22 @@ pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 {     (h & 0x00ff_ffff) as f32 / 16_777_216.0 } +/// Seed of the output dither: fixed, so a frame converts to the same bytes every time.+const DITHER_SEED: u32 = 0x5bd1_e995;++/// A colour value in 0..1 as an 8-bit code, dithered by pixel position (#2022): the value is+/// scaled to 0..255 and rounded up or down against a per-pixel hash threshold, so the codes of+/// a smooth gradient average to the gradient instead of stepping in bands. A value already on+/// an 8-bit code (anything rendered at 8 bpc) comes out unchanged. Deterministic: depends only+/// on the value and the pixel position, never on a clock or a global generator.+#[inline]+pub fn dither_u8(v: f32, x: u32, y: u32) -> u8 {+    let s = v.clamp(0.0, 1.0) * 255.0;+    // The threshold stays inside (1e-4, 1 - 1e-4) so float error on an exact code can't flip it.+    let t = hash_noise(x, y, DITHER_SEED) * (1.0 - 2e-4) + 1e-4;+    (s + t).floor() as u8+}+ /// A single-channel coverage mask. #[derive(Clone, Debug, Default, PartialEq)] pub struct Mask {@@ -345,6 +382,35 @@ impl Mask { mod tests {     use super::*; +    #[test]+    fn dither_keeps_exact_codes_and_averages_the_rest() {+        // A value on an 8-bit code is that code at every position.+        for code in 0..=255u32 {+            let v = code as f32 / 255.0;+            for (x, y) in [(0, 0), (1, 0), (17, 3), (1919, 1079), (4095, 4095)] {+                assert_eq!(dither_u8(v, x, y) as u32, code, "code {code} at ({x}, {y})");+            }+        }+        // Out-of-range and NaN values saturate instead of wrapping.+        assert_eq!((dither_u8(-3.0, 5, 5), dither_u8(7.0, 5, 5), dither_u8(f32::NAN, 5, 5)), (0, 255, 0));+        // A value between two codes lands on both, in proportion: the mean over a block is the value.+        let v = (100.0 + 0.3) / 255.0;+        let n = 64u32;+        let sum: u32 = (0..n * n).map(|i| dither_u8(v, i % n, i / n) as u32).sum();+        let mean = sum as f32 / (n * n) as f32;+        assert!((mean - 100.3).abs() < 0.03, "{mean}");+        // Deterministic.+        assert_eq!(dither_u8(v, 9, 4), dither_u8(v, 9, 4));+    }++    #[test]+    fn dithered_conversions_match_the_plain_ones_on_exact_codes() {+        let src: Vec<u8> = vec![255, 128, 0, 255, 10, 20, 30, 255, 0, 0, 0, 255, 1, 2, 3, 255];+        let img = Image::from_rgba8(2, 2, &src);+        assert_eq!(img.to_rgba8_dithered(), img.to_rgba8());+        assert_eq!(img.to_rgba8_over_dithered([0.0; 3]), img.to_rgba8_over([0.0; 3]));+    }+     #[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];