crates/veilvoice-video/src/raster.rs
what this file is for · veilvoice-video · 594 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Pixels, and a PNG to put them in.
//!
//! # Why the video's pictures are drawn here rather than converted
//!
//! Everything else this crate draws is SVG, which is right for a page: the
//! reader's browser has a renderer and a font library and neither is this
//! project's problem. A video file is not a page. `ffmpeg` reads a directory of
//! raster images, and **no build of ffmpeg can be assumed to read SVG**: that
//! needs librsvg, which most builds do not have, and a picture that fails to
//! encode on somebody's machine is worse than one that was never offered.
//!
//! The other way is to convert the SVG here, which means an SVG rasteriser.
//! Every usable one is large, and [`crate::ffmpeg`] already spends a page
//! explaining why this project will not pull in a large library to make a video
//! file. Doing it anyway, one module over, would make that argument a thing
//! this project says rather than a thing it does.
//!
//! So the picture is a few hundred lines: rectangles, circles, a face
//! ([`crate::font`]) and a PNG writer. That is the whole of what the drawing
//! needs, and it is small enough to read.
//!
//! # The same picture on every machine
//!
//! No system fonts, no floating-point that varies by platform in a way that
//! reaches a pixel, and no dependency that could quietly change its output. Two
//! people rendering the same recording get identical files, which is the same
//! property the reproducible builds have and is checked the same way: by
//! comparing bytes.
//!
//! # One dependency, and what it is for
//!
//! `miniz_oxide` deflates the pixel data, because PNG is deflate and a PNG
//! written with stored blocks is roughly six megabytes a frame. It is pure Rust
//! and it is **already in this tree**, underneath `flate2`, which `lofty` and
//! `pgp` both pull in, so naming it here adds nothing to the dependency graph
//! that was not being compiled already.
//!
//! # In plain words
//!
//! The part that draws the video's pictures, dot by dot, and writes them as PNG
//! files.
//!
//! It is written here rather than borrowed because the video tool this hands its
//! pictures to cannot be relied on to read drawings, and converting them would
//! mean carrying a large piece of somebody else's code, which is the thing this
//! program is careful not to do.
use crate::font;
/// A colour, as the three bytes a PNG stores.
pub type Rgb = [u8; 3];
/// Read `#rrggbb` into three bytes.
///
/// The palettes are written as hex strings because that is what the website and
/// the SVG both want, so this is the one place they become numbers. Anything
/// that is not six hex digits after a `#` gives `None`: a colour that could not
/// be read is a caller's mistake, and guessing at it would paint a frame a
/// colour nobody chose.
pub fn colour(hex: &str) -> Option<Rgb> {
let digits = hex.strip_prefix('#')?;
if digits.len() != 6 {
return None;
}
let mut out = [0u8; 3];
for (at, slot) in out.iter_mut().enumerate() {
*slot = u8::from_str_radix(digits.get(at * 2..at * 2 + 2)?, 16).ok()?;
}
Some(out)
}
/// A picture being drawn, one byte per channel, three channels per pixel.
pub struct Canvas {
width: usize,
height: usize,
pixels: Vec<u8>,
}
impl Canvas {
/// A canvas of `width` by `height`, filled with `background`.
pub fn new(width: usize, height: usize, background: Rgb) -> Self {
let mut pixels = Vec::with_capacity(width * height * 3);
for _ in 0..width * height {
pixels.extend_from_slice(&background);
}
Self {
width,
height,
pixels,
}
}
/// How wide it is.
pub fn width(&self) -> usize {
self.width
}
/// How tall it is.
pub fn height(&self) -> usize {
self.height
}
/// The colour at a point, or `None` outside the canvas. For tests.
pub fn at(&self, x: usize, y: usize) -> Option<Rgb> {
if x >= self.width || y >= self.height {
return None;
}
let at = (y * self.width + x) * 3;
Some([self.pixels[at], self.pixels[at + 1], self.pixels[at + 2]])
}
/// Mix `colour` into the pixel at `x`, `y` by `alpha`, from 0.0 to 1.0.
///
/// Everything that draws a curve goes through this: a circle with a hard
/// edge looks like a mistake at any size, and the fix is to let the edge
/// pixels be part one colour and part the other.
fn blend(&mut self, x: usize, y: usize, colour: Rgb, alpha: f32) {
if x >= self.width || y >= self.height || alpha <= 0.0 {
return;
}
let alpha = alpha.min(1.0);
let at = (y * self.width + x) * 3;
for (channel, want) in self.pixels[at..at + 3].iter_mut().zip(colour) {
let had = *channel as f32;
*channel = (had + (want as f32 - had) * alpha)
.round()
.clamp(0.0, 255.0) as u8;
}
}
/// A filled rectangle, clipped to the canvas.
///
/// Takes signed coordinates because the layout is computed in floats and a
/// box can legitimately start off the left edge; clipping here is one check
/// rather than one at every call site.
pub fn rect(&mut self, x: i64, y: i64, width: i64, height: i64, colour: Rgb) {
if width <= 0 || height <= 0 {
return;
}
let left = x.max(0) as usize;
let top = y.max(0) as usize;
let right = (x + width).clamp(0, self.width as i64) as usize;
let bottom = (y + height).clamp(0, self.height as i64) as usize;
for row in top..bottom {
for column in left..right {
let at = (row * self.width + column) * 3;
self.pixels[at..at + 3].copy_from_slice(&colour);
}
}
}
/// A rectangle with rounded ends, which is how the level bars are drawn.
///
/// The radius is capped at half the shorter side, because a corner rounder
/// than the box it is on is not a shape.
pub fn rounded_rect(&mut self, x: f32, y: f32, width: f32, height: f32, colour: Rgb) {
if width <= 0.0 || height <= 0.0 {
return;
}
let radius = (height / 2.0).min(width / 2.0);
// The straight middle, then a disc at each end. Cheaper than testing
// every pixel against four corner arcs, and the seams are exact
// because the discs sit on the ends rather than near them.
self.rect(
(x + radius).round() as i64,
y.round() as i64,
(width - radius * 2.0).round() as i64,
height.round() as i64,
colour,
);
self.circle(x + radius, y + height / 2.0, radius, colour);
self.circle(x + width - radius, y + height / 2.0, radius, colour);
}
/// A filled circle with a smooth edge.
///
/// Coverage is sampled on a four-by-four grid inside each pixel that the
/// edge crosses. Sixteen samples is enough that no step is visible at any
/// frame size this renders, and pixels well inside or well outside are
/// filled or skipped without sampling at all, so the cost is the outline
/// rather than the area.
pub fn circle(&mut self, centre_x: f32, centre_y: f32, radius: f32, colour: Rgb) {
if radius <= 0.0 {
return;
}
let left = ((centre_x - radius).floor() as i64).max(0) as usize;
let top = ((centre_y - radius).floor() as i64).max(0) as usize;
let right = ((centre_x + radius).ceil() as i64).clamp(0, self.width as i64) as usize;
let bottom = ((centre_y + radius).ceil() as i64).clamp(0, self.height as i64) as usize;
let inner = (radius - 0.75).max(0.0);
let outer = radius + 0.75;
for row in top..bottom {
for column in left..right {
let dx = column as f32 + 0.5 - centre_x;
let dy = row as f32 + 0.5 - centre_y;
let distance = (dx * dx + dy * dy).sqrt();
if distance <= inner {
self.blend(column, row, colour, 1.0);
continue;
}
if distance >= outer {
continue;
}
let mut inside = 0u32;
for sample_y in 0..4 {
for sample_x in 0..4 {
let sx = column as f32 + (sample_x as f32 + 0.5) / 4.0 - centre_x;
let sy = row as f32 + (sample_y as f32 + 0.5) / 4.0 - centre_y;
if sx * sx + sy * sy <= radius * radius {
inside += 1;
}
}
}
self.blend(column, row, colour, inside as f32 / 16.0);
}
}
}
/// A ring, drawn as a filled disc with the middle taken back out.
pub fn ring(&mut self, centre_x: f32, centre_y: f32, radius: f32, thickness: f32, colour: Rgb) {
let inner = (radius - thickness).max(0.0);
let kept: Vec<(usize, usize, Rgb)> = {
let left = ((centre_x - inner).floor() as i64).max(0) as usize;
let top = ((centre_y - inner).floor() as i64).max(0) as usize;
let right = ((centre_x + inner).ceil() as i64).clamp(0, self.width as i64) as usize;
let bottom = ((centre_y + inner).ceil() as i64).clamp(0, self.height as i64) as usize;
let mut held = Vec::new();
for row in top..bottom {
for column in left..right {
let dx = column as f32 + 0.5 - centre_x;
let dy = row as f32 + 0.5 - centre_y;
if dx * dx + dy * dy <= inner * inner {
if let Some(had) = self.at(column, row) {
held.push((column, row, had));
}
}
}
}
held
};
self.circle(centre_x, centre_y, radius, colour);
// Put back what was under the middle, so a ring over a drawn
// background does not punch a hole in it.
for (column, row, had) in kept {
self.blend(column, row, had, 1.0);
}
}
/// Draw `text` with its left edge at `x` and its top at `y`.
///
/// Returns how many characters had no glyph, so a caller can say which
/// names came out as boxes rather than leaving somebody to notice.
pub fn text(&mut self, x: i64, y: i64, text: &str, scale: usize, colour: Rgb) -> usize {
let mut missing = 0;
let mut pen = x;
for character in text.chars() {
let (rows, known) = font::glyph(character);
if !known {
missing += 1;
}
for (row, bits) in rows.iter().enumerate() {
for column in 0..font::WIDTH {
if bits & (1 << (font::WIDTH - 1 - column)) == 0 {
continue;
}
self.rect(
pen + (column * scale) as i64,
y + (row * scale) as i64,
scale as i64,
scale as i64,
colour,
);
}
}
pen += ((font::WIDTH + font::GAP) * scale) as i64;
}
missing
}
/// Draw `text` centred on `centre_x`, with its top at `y`.
pub fn text_centred(
&mut self,
centre_x: f32,
y: i64,
text: &str,
scale: usize,
colour: Rgb,
) -> usize {
let width = font::width_of(text, scale) as f32;
self.text(
(centre_x - width / 2.0).round() as i64,
y,
text,
scale,
colour,
)
}
/// The picture as a PNG file.
///
/// Truecolour, eight bits a channel, no interlacing and no alpha: the
/// frames are opaque, and an alpha channel nothing uses is a third more
/// bytes through the encoder for every frame of the video.
pub fn png(&self) -> Vec<u8> {
// Each row is prefixed with its filter type. Zero, meaning none: these
// are flat colour and large runs, which deflate handles directly, and
// the adaptive filters cost a pass over every row to save little.
let mut raw = Vec::with_capacity(self.height * (1 + self.width * 3));
for row in 0..self.height {
raw.push(0);
let at = row * self.width * 3;
raw.extend_from_slice(&self.pixels[at..at + self.width * 3]);
}
// Six: the default. Nine costs noticeably more time for a frame of flat
// colour that is already almost all runs.
let squeezed = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6);
let mut out = Vec::with_capacity(squeezed.len() + 64);
out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
let mut header = Vec::with_capacity(13);
header.extend_from_slice(&(self.width as u32).to_be_bytes());
header.extend_from_slice(&(self.height as u32).to_be_bytes());
header.extend_from_slice(&[8, 2, 0, 0, 0]);
chunk(&mut out, b"IHDR", &header);
chunk(&mut out, b"IDAT", &squeezed);
chunk(&mut out, b"IEND", &[]);
out
}
}
/// Append one PNG chunk: length, type, data, and the checksum over both.
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(data);
let mut crc = Crc::new();
crc.eat(kind);
crc.eat(data);
out.extend_from_slice(&crc.done().to_be_bytes());
}
/// The CRC-32 PNG puts on every chunk.
///
/// Fifteen lines rather than a dependency: this is the one checksum PNG needs
/// and the polynomial is in the specification.
struct Crc(u32);
impl Crc {
fn new() -> Self {
Self(0xffff_ffff)
}
fn eat(&mut self, bytes: &[u8]) {
for byte in bytes {
self.0 ^= *byte as u32;
for _ in 0..8 {
// The reflected polynomial, which is what PNG specifies.
self.0 = if self.0 & 1 != 0 {
(self.0 >> 1) ^ 0xedb8_8320
} else {
self.0 >> 1
};
}
}
}
fn done(self) -> u32 {
self.0 ^ 0xffff_ffff
}
}
#[cfg(test)]
mod tests {
use super::*;
const BLACK: Rgb = [0, 0, 0];
const WHITE: Rgb = [255, 255, 255];
#[test]
fn a_colour_is_read_or_refused() {
assert_eq!(colour("#1a1b26"), Some([0x1a, 0x1b, 0x26]));
assert_eq!(colour("#FFFFFF"), Some([255, 255, 255]));
for wrong in ["1a1b26", "#1a1b2", "#1a1b26f", "", "#zzzzzz", "#"] {
assert_eq!(colour(wrong), None, "{wrong:?} was read as a colour");
}
}
#[test]
fn a_new_canvas_is_its_background_everywhere() {
let canvas = Canvas::new(4, 3, [1, 2, 3]);
for y in 0..3 {
for x in 0..4 {
assert_eq!(canvas.at(x, y), Some([1, 2, 3]));
}
}
assert_eq!(canvas.at(4, 0), None, "outside is outside");
assert_eq!(canvas.at(0, 3), None);
}
/// Drawing off the edge clips rather than wrapping or panicking.
///
/// The layout is computed in floats and a box can legitimately start left
/// of zero. Wrapping would put a bar on the wrong side of the picture.
#[test]
fn drawing_outside_the_canvas_is_clipped() {
let mut canvas = Canvas::new(4, 4, BLACK);
canvas.rect(-10, -10, 12, 12, WHITE);
assert_eq!(canvas.at(1, 1), Some(WHITE), "the part that is inside");
assert_eq!(canvas.at(3, 3), Some(BLACK), "the part that is outside");
canvas.rect(100, 100, 10, 10, WHITE);
canvas.circle(-50.0, -50.0, 3.0, WHITE);
canvas.circle(1000.0, 1000.0, 3.0, WHITE);
assert_eq!(canvas.at(3, 3), Some(BLACK), "nothing wrapped round");
}
/// A circle is filled in the middle, empty at the corners, and soft at the
/// edge.
#[test]
fn a_circle_is_round_and_its_edge_is_not_a_staircase() {
let mut canvas = Canvas::new(41, 41, BLACK);
canvas.circle(20.5, 20.5, 15.0, WHITE);
assert_eq!(canvas.at(20, 20), Some(WHITE), "the middle");
assert_eq!(canvas.at(0, 0), Some(BLACK), "a corner");
// Somewhere on the rim there is a pixel that is neither, which is what
// a smooth edge means and what a hard-edged circle would not have.
let mut partial = 0;
for y in 0..41 {
for x in 0..41 {
let shade = canvas.at(x, y).unwrap()[0];
if shade > 0 && shade < 255 {
partial += 1;
}
}
}
assert!(
partial > 20,
"only {partial} pixels are part-covered, so the edge is a staircase"
);
}
/// A ring is hollow.
#[test]
fn a_ring_leaves_what_was_under_its_middle() {
let mut canvas = Canvas::new(41, 41, BLACK);
canvas.rect(0, 0, 41, 41, [7, 7, 7]);
canvas.ring(20.5, 20.5, 15.0, 3.0, WHITE);
assert_eq!(
canvas.at(20, 20),
Some([7, 7, 7]),
"the middle is untouched"
);
assert_eq!(canvas.at(20, 6), Some(WHITE), "the rim is drawn");
}
/// Text lands where it was put and reports what it could not draw.
#[test]
fn text_is_drawn_and_says_what_it_could_not() {
let mut canvas = Canvas::new(80, 20, BLACK);
let missing = canvas.text(2, 2, "Hi", 2, WHITE);
assert_eq!(missing, 0);
let lit = (0..20)
.flat_map(|y| (0..80).map(move |x| (x, y)))
.filter(|(x, y)| canvas.at(*x, *y) == Some(WHITE))
.count();
assert!(lit > 0, "nothing was drawn");
let mut other = Canvas::new(80, 20, BLACK);
assert_eq!(other.text(2, 2, "Zoë", 2, WHITE), 1, "one box");
}
/// Centred text is centred.
#[test]
fn centred_text_sits_on_its_centre() {
let mut canvas = Canvas::new(101, 20, BLACK);
canvas.text_centred(50.0, 2, "ii", 2, WHITE);
let columns: Vec<usize> = (0..101)
.filter(|x| (0..20).any(|y| canvas.at(*x, y) == Some(WHITE)))
.collect();
let left = *columns.first().expect("something was drawn");
let right = *columns.last().unwrap();
let middle = (left + right) as f32 / 2.0;
assert!(
(middle - 50.0).abs() <= 2.0,
"drawn from {left} to {right}, centred on {middle} rather than 50"
);
}
/// The PNG is a PNG: signature, the three chunks, and correct checksums.
///
/// Checked by reading the bytes rather than by opening the file with
/// something, because "a decoder accepted it" and "it is what the format
/// says" are different claims and this writer makes the second.
#[test]
fn the_png_is_shaped_the_way_the_format_says() {
let mut canvas = Canvas::new(7, 5, [10, 20, 30]);
canvas.circle(3.5, 2.5, 2.0, [200, 100, 50]);
let png = canvas.png();
assert_eq!(
&png[..8],
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a],
"signature"
);
// Walk the chunks, checking each length and checksum as it goes.
let mut at = 8;
let mut seen = Vec::new();
while at + 12 <= png.len() {
let length = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize;
let kind = String::from_utf8_lossy(&png[at + 4..at + 8]).into_owned();
let body = &png[at + 8..at + 8 + length];
let stated =
u32::from_be_bytes(png[at + 8 + length..at + 12 + length].try_into().unwrap());
let mut crc = Crc::new();
crc.eat(&png[at + 4..at + 8]);
crc.eat(body);
assert_eq!(crc.done(), stated, "{kind} checksum");
if kind == "IHDR" {
assert_eq!(u32::from_be_bytes(body[0..4].try_into().unwrap()), 7);
assert_eq!(u32::from_be_bytes(body[4..8].try_into().unwrap()), 5);
assert_eq!(
&body[8..],
&[8, 2, 0, 0, 0],
"8-bit truecolour, no interlace"
);
}
seen.push(kind);
at += 12 + length;
}
assert_eq!(at, png.len(), "trailing bytes after the last chunk");
assert_eq!(seen, vec!["IHDR", "IDAT", "IEND"]);
}
/// The pixels survive the round trip.
///
/// Deflated and inflated again, and compared against what was drawn. A
/// writer that produced a well-formed file of the wrong pixels would pass
/// every structural check above.
#[test]
fn the_pixels_come_back_out_as_they_went_in() {
let mut canvas = Canvas::new(9, 6, [10, 20, 30]);
canvas.rect(2, 1, 4, 3, [200, 100, 50]);
let png = canvas.png();
// The IDAT body, found by walking rather than by a fixed offset.
let mut at = 8;
let mut squeezed = Vec::new();
while at + 12 <= png.len() {
let length = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize;
if &png[at + 4..at + 8] == b"IDAT" {
squeezed = png[at + 8..at + 8 + length].to_vec();
}
at += 12 + length;
}
let raw = miniz_oxide::inflate::decompress_to_vec_zlib(&squeezed)
.expect("the deflate stream is readable");
assert_eq!(raw.len(), 6 * (1 + 9 * 3), "one filter byte per row");
for y in 0..6 {
let row = y * (1 + 9 * 3);
assert_eq!(raw[row], 0, "row {y} filter");
for x in 0..9 {
let at = row + 1 + x * 3;
assert_eq!(
[raw[at], raw[at + 1], raw[at + 2]],
canvas.at(x, y).unwrap(),
"pixel {x},{y}"
);
}
}
}
/// The same drawing gives the same bytes.
///
/// Two people rendering one recording should get one file. Nothing here may
/// depend on a hash order, a system font or a clock.
#[test]
fn the_same_picture_encodes_to_the_same_bytes() {
let draw = || {
let mut canvas = Canvas::new(64, 40, [26, 27, 38]);
canvas.circle(20.0, 20.0, 11.0, [122, 162, 247]);
canvas.rounded_rect(30.0, 30.0, 28.0, 6.0, [158, 206, 106]);
canvas.text(2, 2, "Alex", 2, [192, 202, 245]);
canvas.png()
};
assert_eq!(draw(), draw());
}
}