diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index fd1ace60b..1b69a87bd 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -376,7 +376,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> = 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 index 66c2177be..c639ebad1 100644 --- 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; @@ -220,6 +218,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. @@ -360,14 +361,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())); @@ -384,8 +377,10 @@ impl<'a> Cx<'a> { return None; } let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None }; - let depth = self.job.settings.color_depth; - if depth != ColorDepth::Current && depth.resolve(self.project.settings.bit_depth).levels().is_some() { + // Above 8 bpc the colour is dithered on its way to 8 bits (`Cx::pixels`), which the + // accelerator's conversion doesn't do: those frames render as usual so H.264 doesn't + // band (#2022). The project's depth already includes Render Settings ▸ Color Depth. + if self.project.settings.bit_depth != BitDepth::Bpc8 { return None; } let om = self.job.output; @@ -453,12 +448,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 { + 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 { 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 @@ -619,15 +626,22 @@ fn bicubic(src: &Image, w: u32, h: u32) -> Image { out } -/// Premultiplied 8-bit RGBA (colour matted with black). -fn premultiplied8(img: &Image) -> Vec { - 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 { + 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 a8 = (a * 255.0 + 0.5) as u8; + let (x, y) = ((i % w) as u32, (i / w) as u32); + for c in 0..3 { + let v = p[c].clamp(0.0, a); + // Dither may round up past the rounded alpha; premultiplied colour never exceeds it. + out.push(if dither { effectcraft_raster::dither_u8(v, x, y).min(a8) } else { (v * 255.0 + 0.5) as u8 }); + } + out.push(a8); + } + out } #[cfg(test)] @@ -636,6 +650,19 @@ mod tests { use rayon::prelude::*; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + /// Dither may round a colour up while alpha rounds down; premultiplied colour stays at or + /// below the written alpha at every pixel. + #[test] + fn dithered_premultiplied_colour_never_exceeds_alpha() { + let v = 100.25 / 255.0; + let img = Image::filled(64, 64, [v, v, v, v]); + let px = premultiplied8(&img, true); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|c| *c <= p[3])), "a colour byte is above its alpha"); + assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 100)); + let plain = premultiplied8(&img, false); + assert!(plain.as_chunks::<4>().0.iter().all(|p| *p == [100, 100, 100, 100])); + } + /// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once. fn run_capped(n: u64, cap: Option) -> (Vec, usize) { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/export/tests/it/banding.rs b/crates/export/tests/it/banding.rs new file mode 100644 index 000000000..3f99f1eb6 --- /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> { + let files: Arc)>>> = Default::default(); + let f = files.clone(); + let sink: Box = Box::new(move |path: &str, data: Vec| 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]) -> 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]) -> 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/h264_gpu_yuv.rs b/crates/export/tests/it/h264_gpu_yuv.rs index 59e670779..0f99a7b54 100644 --- a/crates/export/tests/it/h264_gpu_yuv.rs +++ b/crates/export/tests/it/h264_gpu_yuv.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use effectcraft_color::Label; -use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan}; +use effectcraft_export::render_queue::{ColorDepth, OutputFormat, OutputModule, RenderSettings, TimeSpan}; use effectcraft_export::{Job, export}; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build}; use effectcraft_raster::Image; @@ -147,6 +147,20 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() { assert!(a == c); } +/// Above 8 bpc the colour is dithered on its way to 8 bits, which the accelerator's planes +/// don't do: those frames take the RGBA path, so an H.264 export doesn't band (#2022). +#[test] +fn renders_above_8_bpc_skip_the_planes() { + let d = out_dir("h264-gpu-yuv-16bpc"); + let (p, cid) = project(); + let s = RenderSettings { time_span: TimeSpan::LengthOfComp, color_depth: ColorDepth::Bpc16, ..Default::default() }; + let yuv = Fake::new(true, false, usize::MAX); + let a = run(&p, cid, Some(&yuv), &s, &d.join("yuv.mp4")); + let c = run(&p, cid, None, &s, &d.join("cpu.mp4")); + assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0, "no planes asked for above 8 bpc"); + assert!(a == c); +} + /// With deferred readbacks the export keeps rendering frames in passes (`Cx::frames`), never /// through the blocking per-frame path the planes use. #[test] diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index 0064ba8b7..88398a0e8 100644 --- 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/.rs`). mod audio_sample_positions; +mod banding; mod h264_gpu_yuv; mod hevc_av1_roundtrip; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 01960b66b..fd2fd183b 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -242,7 +242,7 @@ impl Image { } /// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored). pub fn to_rgba8(&self) -> Vec { - 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 { @@ -251,9 +251,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 { + 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 { - 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; @@ -261,13 +272,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 { + /// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]). + pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec { + 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 { 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 @@ -342,6 +363,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 { @@ -366,6 +403,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 = 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 = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4];