mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 21:48:20 -06:00
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:
+39
-59
@@ -33,18 +33,29 @@ pub(crate) enum Pixels {
|
||||
Yuv420(Yuv420),
|
||||
}
|
||||
|
||||
/// BT.709 limited-range 8-bit 4:2:0 planes ([`rgba_to_yuv420`]).
|
||||
pub(crate) struct Yuv420 {
|
||||
y: Vec<u8>,
|
||||
u: Vec<u8>,
|
||||
v: Vec<u8>,
|
||||
/// BT.709 limited-range 8-bit 4:2:0 planes ([`rgba_to_yuv420`], or Y then U and V from the
|
||||
/// accelerator).
|
||||
pub(crate) enum Yuv420 {
|
||||
Planes { y: Vec<u8>, u: Vec<u8>, v: Vec<u8> },
|
||||
Packed(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Yuv420 {
|
||||
fn from_rgba(rgba: &[u8], w: usize, h: usize) -> Yuv420 {
|
||||
let (mut y, mut u, mut v) = (vec![], vec![], vec![]);
|
||||
rgba_to_yuv420(rgba, w, h, &mut y, &mut u, &mut v);
|
||||
Yuv420 { y, u, v }
|
||||
Yuv420::Planes { y, u, v }
|
||||
}
|
||||
fn slices(&self, w: usize, h: usize) -> (&[u8], &[u8], &[u8]) {
|
||||
match self {
|
||||
Yuv420::Planes { y, u, v } => (y, u, v),
|
||||
Yuv420::Packed(p) => {
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let (y, uv) = p.split_at(w * h);
|
||||
let (u, v) = uv.split_at(cw * ch);
|
||||
(y, u, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,18 +113,22 @@ impl VideoEncoder for H264 {
|
||||
SampleEntry::avc(cfg, self.w as u16, self.h as u16)
|
||||
}
|
||||
fn encode(&mut self, rgba: &[u8], index: u64) -> Result<Vec<Packet>> {
|
||||
let mut planes = Yuv420 { y: std::mem::take(&mut self.y), u: std::mem::take(&mut self.u), v: std::mem::take(&mut self.v) };
|
||||
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut planes.y, &mut planes.u, &mut planes.v);
|
||||
let (mut y, mut u, mut v) = (std::mem::take(&mut self.y), std::mem::take(&mut self.u), std::mem::take(&mut self.v));
|
||||
rgba_to_yuv420(rgba, self.w as usize, self.h as usize, &mut y, &mut u, &mut v);
|
||||
let planes = Yuv420::Planes { y, u, v };
|
||||
let r = self.encode_yuv420(&planes, index);
|
||||
(self.y, self.u, self.v) = (planes.y, planes.u, planes.v);
|
||||
if let Yuv420::Planes { y, u, v } = planes {
|
||||
(self.y, self.u, self.v) = (y, u, v);
|
||||
}
|
||||
r
|
||||
}
|
||||
fn takes_yuv420(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn encode_yuv420(&mut self, p: &Yuv420, index: u64) -> Result<Vec<Packet>> {
|
||||
let cw = (self.w as usize).div_ceil(2);
|
||||
let frame = filmcraft_h264enc::YuvFrame { y: &p.y, u: &p.u, v: &p.v, y_stride: self.w as usize, uv_stride: cw };
|
||||
let (w, cw) = (self.w as usize, (self.w as usize).div_ceil(2));
|
||||
let (y, u, v) = p.slices(w, self.h as usize);
|
||||
let frame = filmcraft_h264enc::YuvFrame { y, u, v, y_stride: w, uv_stride: cw };
|
||||
let ps = self.enc.try_encode(&frame, index as i64 * self.rate.den).map_err(enc)?;
|
||||
Ok(self.packets(ps))
|
||||
}
|
||||
@@ -126,51 +141,7 @@ impl VideoEncoder for H264 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v / 255` for every 8-bit `v` (the same values as dividing).
|
||||
pub(crate) 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(crate) 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(crate) 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
pub(crate) use effectcraft_raster::yuv::rgba_to_yuv420;
|
||||
|
||||
// ---------------------------------------------------------------- ProRes
|
||||
|
||||
@@ -326,6 +297,13 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
let px = job.pixels(&img, comp, channels, w, h);
|
||||
if yuv { Pixels::Yuv420(Yuv420::from_rgba(&px, w as usize, h as usize)) } else { Pixels::Rgba(px) }
|
||||
};
|
||||
// H.264 frames the accelerator can convert itself come back as planes (12 MB instead of
|
||||
// 133 MB of RGBA f32 read back at 4K); everything else renders and converts as before.
|
||||
// Deferred readbacks (a browser worker) keep their pass-based path: no blocking there.
|
||||
let gpu_yuv = yuv && channels == Channels::Rgb && !job.deferred();
|
||||
let batch_frames = async |ks: Vec<u64>| -> Vec<Pixels> {
|
||||
if gpu_yuv { job.h264_frames(comp, ks, w, h, |p| Pixels::Yuv420(Yuv420::Packed(p)), render) } else { job.frames(comp, ks, render).await }
|
||||
};
|
||||
let encode = |venc: &mut Box<dyn VideoEncoder>, px: &Pixels, index: u64| match px {
|
||||
Pixels::Rgba(rgba) => venc.encode(rgba, index),
|
||||
Pixels::Yuv420(planes) => venc.encode_yuv420(planes, index),
|
||||
@@ -337,10 +315,10 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
// Encode the first batch before creating tracks: encoders finalise their config on frame 1.
|
||||
// Each batch encodes while the next one renders.
|
||||
let first_end = batch.min(total);
|
||||
let first: Vec<Pixels> = job.frames(comp, (0..first_end).collect(), render).await;
|
||||
let first: Vec<Pixels> = batch_frames((0..first_end).collect()).await;
|
||||
let next_end = (first_end + batch).min(total);
|
||||
let (pending, mut ready) = job
|
||||
.frames_beside(beside, comp, (first_end..next_end).collect(), render, || -> Result<Vec<Packet>> {
|
||||
.beside(beside, batch_frames((first_end..next_end).collect()), || -> Result<Vec<Packet>> {
|
||||
let mut pending = Vec::new();
|
||||
for (k, px) in first.iter().enumerate() {
|
||||
pending.extend(encode(&mut venc, px, k as u64)?);
|
||||
@@ -435,7 +413,7 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
let next_end = (end + batch).min(total);
|
||||
let frames = std::mem::take(&mut ready);
|
||||
let (done, next) = job
|
||||
.frames_beside(beside, comp, (end..next_end).collect(), render, || -> Result<()> {
|
||||
.beside(beside, batch_frames((end..next_end).collect()), || -> Result<()> {
|
||||
for (k, px) in frames.iter().enumerate() {
|
||||
let ps = encode(&mut venc, px, i + k as u64)?;
|
||||
write_video(&mut mux, ps)?;
|
||||
@@ -463,6 +441,8 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use effectcraft_raster::yuv::{UNIT8, round_pos};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The conversion as first written (per-pixel division and `f32::round`).
|
||||
|
||||
@@ -270,7 +270,7 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>)
|
||||
let path = sequence_path(cx.path, f0 + k as i64);
|
||||
// Encode in memory first: the storage hook decides where it fits.
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
write_still(cx, &mut buf, fmt, &img, comp, channels, w, h, cx.output.quality)?;
|
||||
write_still(cx, &mut buf, &img, comp, StillOutput { format: fmt, channels, size: (w, h), quality: cx.output.quality })?;
|
||||
let data = buf.into_inner();
|
||||
let path = cx.place(&path, data.len() as u64);
|
||||
let mut f = cx.create(&path)?;
|
||||
@@ -287,18 +287,15 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>)
|
||||
Ok(Report { path: sequence_path(cx.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_still<W: std::io::Write + std::io::Seek>(
|
||||
cx: &Cx,
|
||||
file: &mut W,
|
||||
fmt: OutputFormat,
|
||||
img: &Image,
|
||||
comp: &Comp,
|
||||
struct StillOutput {
|
||||
format: OutputFormat,
|
||||
channels: Channels,
|
||||
w: u32,
|
||||
h: u32,
|
||||
size: (u32, u32),
|
||||
quality: u8,
|
||||
) -> Result<()> {
|
||||
}
|
||||
|
||||
fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &Image, comp: &Comp, output: StillOutput) -> Result<()> {
|
||||
let StillOutput { format: fmt, channels, size: (w, h), quality } = output;
|
||||
use image::{ExtendedColorType, ImageEncoder, ImageFormat};
|
||||
match fmt {
|
||||
OutputFormat::ExrSequence => {
|
||||
|
||||
@@ -236,6 +236,7 @@ impl<'a> Cx<'a> {
|
||||
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
|
||||
/// render threads don't wait for the (largely sequential) encoder of the previous batch.
|
||||
/// With deferred readbacks, or where no encode thread could be started, `encode` runs first.
|
||||
#[cfg(test)]
|
||||
pub async fn frames_beside<T: Send, A: Send>(
|
||||
&self,
|
||||
beside: &Beside,
|
||||
@@ -243,13 +244,24 @@ impl<'a> Cx<'a> {
|
||||
ks: Vec<u64>,
|
||||
f: impl Fn(u64, Image) -> T + Sync + Send,
|
||||
encode: impl FnOnce() -> A + Send,
|
||||
) -> crate::Result<(A, Vec<T>)> {
|
||||
self.beside(beside, self.frames(comp, ks, f), encode).await
|
||||
}
|
||||
|
||||
/// Run `encode` on `beside`'s threads while `frames` renders the next batch. With deferred
|
||||
/// readbacks, or where no encode thread could be started, `encode` runs first.
|
||||
pub async fn beside<T: Send, A: Send>(
|
||||
&self,
|
||||
beside: &Beside,
|
||||
frames: impl std::future::Future<Output = Vec<T>>,
|
||||
encode: impl FnOnce() -> A + Send,
|
||||
) -> crate::Result<(A, Vec<T>)> {
|
||||
match &beside.0 {
|
||||
Some(pool) if !self.deferred() => {
|
||||
let mut done = None;
|
||||
let frames = pool.in_place_scope(|s| {
|
||||
s.spawn(|_| done = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(encode))));
|
||||
effectcraft_render::passes::block_on(self.frames(comp, ks, f))
|
||||
effectcraft_render::passes::block_on(frames)
|
||||
});
|
||||
match done {
|
||||
Some(Ok(a)) => Ok((a, frames)),
|
||||
@@ -262,7 +274,7 @@ impl<'a> Cx<'a> {
|
||||
}
|
||||
_ => {
|
||||
let a = encode();
|
||||
Ok((a, self.frames(comp, ks, f).await))
|
||||
Ok((a, frames.await))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +309,65 @@ impl<'a> Cx<'a> {
|
||||
img
|
||||
}
|
||||
|
||||
/// Output frame `i` as the H.264 export's 4:2:0 planes at `w`×`h`, rendered and converted
|
||||
/// by the accelerator ([`effectcraft_render::Accelerator::comp_frame_yuv420`]). Only for
|
||||
/// frames that need nothing done after the render (progressive, at the project's depth, no
|
||||
/// crop or resize, rendered at exactly `w`×`h`). `None`: render it with [`Cx::frame`].
|
||||
pub fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32) -> Option<Vec<u8>> {
|
||||
if self.deferred() {
|
||||
return None;
|
||||
}
|
||||
let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None };
|
||||
// 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;
|
||||
let scale = self.job.settings.resolution.clamp(0.01, 4.0);
|
||||
let size = |n: u32| ((n as f64 * scale).round() as u32).max(1);
|
||||
if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) {
|
||||
return None;
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
let r = self.renderer();
|
||||
let planes = r.active_accel()?.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?;
|
||||
let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize);
|
||||
if planes.len() != w as usize * h as usize + 2 * cw * ch {
|
||||
return None;
|
||||
}
|
||||
if let Some(ft) = &self.frame_times {
|
||||
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
Some(planes)
|
||||
}
|
||||
|
||||
/// Output frames `ks` for the H.264 encoder: the accelerator's planes
|
||||
/// ([`Cx::yuv420_frame`]) where it can, otherwise `fallback` of the frame rendered as usual.
|
||||
pub(crate) fn h264_frames<T: Send>(
|
||||
&self,
|
||||
comp: &Comp,
|
||||
ks: Vec<u64>,
|
||||
w: u32,
|
||||
h: u32,
|
||||
planes: impl Fn(Vec<u8>) -> T + Sync + Send,
|
||||
fallback: impl Fn(u64, Image) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
// As in `frames`: once the GPU has stopped for good, the rest render in parallel.
|
||||
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
|
||||
map_frames_while(
|
||||
ks,
|
||||
self.gpu_cap(),
|
||||
gpu_live,
|
||||
|k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)),
|
||||
|k, r| match r {
|
||||
Ok(p) => planes(p),
|
||||
Err(img) => fallback(k, img),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Output Module ▸ Crop, then Resize.
|
||||
fn crop_resize(&self, img: Image) -> Image {
|
||||
let om = self.job.output;
|
||||
|
||||
Reference in New Issue
Block a user