Motion animation export: a study or a joint drive as an animated GIF (#1680)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-16 07:26:15 -04:00
1 parent 8207f29fc0
commit 2ae21143ee
24 files changed
+902 -33

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ image = { workspace = true, features = ["jpeg"] }
moxcms = { workspace = true }
png = { workspace = true }
sha2 = { workspace = true }
weezl = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+321
View File
@@ -0,0 +1,321 @@
//! Animated GIF (GIF89a) from RGBA frames, for motion animations. Every frame shares one palette,
//! so a colour keeps its value from frame to frame, and each frame after the first stores only
//! the rectangle that changed. The palette splits the frames' colours where that removes the
//! most squared colour error, so a small, strongly coloured part (a red part, a thin trace line)
//! keeps its colour instead of being averaged into the shading around it. No dithering: what
//! stands still stays still.
use std::collections::HashMap;
/// Colours a GIF palette holds.
const PALETTE: usize = 256;
/// Frames (straight RGBA, `width × height`, rows top to bottom; alpha is ignored) as a GIF that
/// loops forever and shows each frame for `delay` hundredths of a second. `None` without frames,
/// for a frame of the wrong size, or a side GIF can't store (above 65535).
pub fn gif(width: usize, height: usize, frames: &[Vec<u8>], delay: u16) -> Option<Vec<u8>> {
let (w, h) = (u16::try_from(width).ok().filter(|w| *w > 0)?, u16::try_from(height).ok().filter(|h| *h > 0)?);
let bytes = width.checked_mul(height)?.checked_mul(4)?;
if frames.is_empty() || frames.iter().any(|f| f.len() != bytes) {
return None;
}
let (palette, index) = palette(frames);
let mut out = Vec::with_capacity(1024);
out.extend_from_slice(b"GIF89a");
out.extend_from_slice(&w.to_le_bytes());
out.extend_from_slice(&h.to_le_bytes());
// A global table of 256 colours (8 bits each), background colour 0, square pixels.
out.extend_from_slice(&[0xF7, 0, 0]);
for k in 0..PALETTE {
out.extend_from_slice(palette.get(k).unwrap_or(&[0, 0, 0]));
}
// Loop forever (the NETSCAPE2.0 extension every viewer reads).
out.extend_from_slice(&[0x21, 0xFF, 0x0B]);
out.extend_from_slice(b"NETSCAPE2.0");
out.extend_from_slice(&[0x03, 0x01, 0x00, 0x00, 0x00]);
let mut previous: Option<Vec<u8>> = None;
for frame in frames {
let now = indices(frame, &index);
let (x0, y0, x1, y1) = previous.as_deref().map_or(Some((0, 0, width - 1, height - 1)), |p| changed(p, &now, width)).unwrap_or((0, 0, 0, 0));
let mut rect = Vec::with_capacity((x1 - x0 + 1) * (y1 - y0 + 1));
for y in y0..=y1 {
rect.extend_from_slice(now.get(y * width + x0..=y * width + x1)?);
}
// Graphic control: kept on screen under the next frame (disposal 1), shown `delay`.
out.extend_from_slice(&[0x21, 0xF9, 0x04, 0x04]);
out.extend_from_slice(&delay.to_le_bytes());
out.extend_from_slice(&[0, 0]);
out.push(0x2C);
for v in [x0, y0, x1 - x0 + 1, y1 - y0 + 1] {
out.extend_from_slice(&u16::try_from(v).ok()?.to_le_bytes());
}
out.push(0);
out.push(8);
let data = weezl::encode::Encoder::new(weezl::BitOrder::Lsb, 8).encode(&rect).ok()?;
for block in data.chunks(255) {
out.push(block.len() as u8);
out.extend_from_slice(block);
}
out.push(0);
previous = Some(now);
}
out.push(0x3B);
Some(out)
}
/// The pixels' colours, `0xRRGGBB`.
fn colours(frame: &[u8]) -> impl Iterator<Item = u32> + '_ {
frame.as_chunks::<4>().0.iter().map(|&[r, g, b, _]| (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b))
}
/// A frame's palette indices.
fn indices(frame: &[u8], index: &HashMap<u32, u8>) -> Vec<u8> {
let mut last = (u32::MAX, 0);
colours(frame)
.map(|k| {
if k != last.0 {
last = (k, index.get(&k).copied().unwrap_or(0));
}
last.1
})
.collect()
}
/// The smallest rectangle (x0, y0, x1, y1, inclusive) holding every pixel that differs, or `None`
/// when none does.
fn changed(before: &[u8], now: &[u8], width: usize) -> Option<(usize, usize, usize, usize)> {
let mut r: Option<(usize, usize, usize, usize)> = None;
for (i, _) in before.iter().zip(now).enumerate().filter(|(_, (a, b))| a != b) {
let (x, y) = (i % width, i / width);
r = Some(r.map_or((x, y, x, y), |(x0, y0, x1, y1)| (x0.min(x), y0.min(y), x1.max(x), y1.max(y))));
}
r
}
/// How many pixels have a colour, and the sums its mean and squared error follow from.
#[derive(Clone, Copy, Default)]
struct Sums {
n: f64,
s: [f64; 3],
q: f64,
}
impl Sums {
fn of(rgb: u32, n: u64) -> Sums {
let n = n as f64;
let c = [f64::from((rgb >> 16) as u8), f64::from((rgb >> 8) as u8), f64::from(rgb as u8)];
Sums { n, s: c.map(|v| n * v), q: n * c.iter().map(|v| v * v).sum::<f64>() }
}
fn add(&mut self, o: &Sums) {
self.n += o.n;
self.q += o.q;
self.s.iter_mut().zip(o.s).for_each(|(a, b)| *a += b);
}
fn minus(&self, o: &Sums) -> Sums {
let mut s = *self;
s.n -= o.n;
s.q -= o.q;
s.s.iter_mut().zip(o.s).for_each(|(a, b)| *a -= b);
s
}
/// The squared distance of the pixels from their mean colour.
fn error(&self) -> f64 {
if self.n <= 0.0 { 0.0 } else { self.q - self.s.iter().map(|s| s * s).sum::<f64>() / self.n }
}
fn mean(&self) -> [u8; 3] {
self.s.map(|s| if self.n > 0.0 { (s / self.n).round().clamp(0.0, 255.0) as u8 } else { 0 })
}
}
/// A set of the frames' colours that becomes one palette entry, and its best split: (error
/// removed, channel, the largest value of that channel on the low side).
struct Group {
colours: Vec<(u32, Sums)>,
sums: Sums,
split: Option<(f64, u32, u8)>,
}
impl Group {
fn new(colours: Vec<(u32, Sums)>) -> Group {
let mut sums = Sums::default();
colours.iter().for_each(|(_, s)| sums.add(s));
let mut best: Option<(f64, u32, u8)> = None;
for channel in [16u32, 8, 0] {
let mut bins = [Sums::default(); 256];
for (k, s) in &colours {
if let Some(b) = bins.get_mut(usize::from((k >> channel) as u8)) {
b.add(s);
}
}
let mut low = Sums::default();
for (v, b) in bins.iter().enumerate() {
low.add(b);
if low.n <= 0.0 || low.n >= sums.n {
continue;
}
let gain = sums.error() - low.error() - sums.minus(&low).error();
if best.is_none_or(|(g, _, _)| gain > g) {
best = Some((gain, channel, v as u8));
}
}
}
Group { colours, sums, split: best.filter(|(g, _, _)| *g > 0.0) }
}
}
/// One palette for all frames (up to 256 colours) and each colour's index in it. Every colour
/// gets its own entry while there are 256 or fewer.
fn palette(frames: &[Vec<u8>]) -> (Vec<[u8; 3]>, HashMap<u32, u8>) {
let mut counts: HashMap<u32, u64> = HashMap::new();
for frame in frames {
// Runs of one colour (the background) are counted once per run.
let mut run = (u32::MAX, 0u64);
for k in colours(frame) {
if k != run.0 {
if run.1 > 0 {
*counts.entry(run.0).or_default() += run.1;
}
run = (k, 0);
}
run.1 += 1;
}
if run.1 > 0 {
*counts.entry(run.0).or_default() += run.1;
}
}
let mut groups = vec![Group::new(counts.into_iter().map(|(k, n)| (k, Sums::of(k, n))).collect())];
while groups.len() < PALETTE {
let Some(i) = groups.iter().enumerate().filter_map(|(i, g)| g.split.map(|s| (i, s.0))).max_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
else {
break;
};
let g = groups.swap_remove(i);
let Some((_, channel, at)) = g.split else { break };
let (low, high) = g.colours.into_iter().partition(|(k, _)| (k >> channel) as u8 <= at);
groups.push(Group::new(low));
groups.push(Group::new(high));
}
let mut index = HashMap::new();
for (i, g) in groups.iter().enumerate() {
for (k, _) in &g.colours {
index.insert(*k, i as u8);
}
}
(groups.iter().map(|g| g.sums.mean()).collect(), index)
}
#[cfg(test)]
mod tests {
use super::*;
/// The frames of a GIF this module wrote, as RGBA (each frame drawn over the one before).
fn decode(gif: &[u8]) -> (usize, usize, Vec<Vec<u8>>, Vec<u16>) {
let u16_at = |i: usize| usize::from(u16::from_le_bytes([gif[i], gif[i + 1]]));
assert_eq!(&gif[..6], b"GIF89a");
let (w, h) = (u16_at(6), u16_at(8));
let table = &gif[13..13 + 768];
let mut at = 13 + 768;
let (mut frames, mut delays) = (Vec::new(), Vec::new());
let mut canvas = vec![0u8; w * h * 4];
loop {
match gif[at] {
0x3B => break,
0x21 => {
if gif[at + 1] == 0xF9 {
delays.push(u16_at(at + 4) as u16);
}
at += 2;
while gif[at] != 0 {
at += usize::from(gif[at]) + 1;
}
at += 1;
}
0x2C => {
let (x0, y0, fw, fh) = (u16_at(at + 1), u16_at(at + 3), u16_at(at + 5), u16_at(at + 7));
at += 11;
let mut data = Vec::new();
while gif[at] != 0 {
data.extend_from_slice(&gif[at + 1..at + 1 + usize::from(gif[at])]);
at += usize::from(gif[at]) + 1;
}
at += 1;
let pixels = weezl::decode::Decoder::new(weezl::BitOrder::Lsb, 8).decode(&data).unwrap();
assert_eq!(pixels.len(), fw * fh);
for (i, p) in pixels.iter().enumerate() {
let o = ((y0 + i / fw) * w + x0 + i % fw) * 4;
let c = usize::from(*p) * 3;
canvas[o..o + 4].copy_from_slice(&[table[c], table[c + 1], table[c + 2], 255]);
}
frames.push(canvas.clone());
}
b => panic!("unexpected block {b:#x} at {at}"),
}
}
(w, h, frames, delays)
}
fn frame(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 3]) -> Vec<u8> {
(0..w * h).flat_map(|i| f(i % w, i / w).into_iter().chain([255])).collect()
}
#[test]
fn few_colours_come_back_exactly_and_frames_keep_their_size() {
let (w, h) = (37, 23);
let a = frame(w, h, |x, y| {
if x < 10 {
[250, 30, 30]
} else if y > 15 {
[20, 200, 60]
} else {
[220, 224, 230]
}
});
// The second frame moves the red band: only that part changes.
let b = frame(w, h, |x, y| {
if (5..15).contains(&x) {
[250, 30, 30]
} else if y > 15 {
[20, 200, 60]
} else {
[220, 224, 230]
}
});
let bytes = gif(w, h, &[a.clone(), b.clone(), b.clone()], 5).unwrap();
let (dw, dh, frames, delays) = decode(&bytes);
assert_eq!((dw, dh, delays), (w, h, vec![5, 5, 5]));
assert_eq!(frames, vec![a, b.clone(), b]);
}
#[test]
fn a_thin_saturated_line_keeps_its_colour_among_many_shades() {
let (w, h) = (300, 200);
// A grey shading with far more than 256 shades, a red part and a 1-pixel green line.
let f = frame(w, h, |x, y| {
if y == 100 {
[30, 220, 40]
} else if (40..80).contains(&x) && (40..80).contains(&y) {
[200 + (y as u8 % 40), 30, 30]
} else {
let g = 60 + ((x * 180) / w) as u8;
[g, g.saturating_add((y / 4) as u8), g.saturating_add(10)]
}
});
let (_, _, frames, _) = decode(&gif(w, h, std::slice::from_ref(&f), 4).unwrap());
let px = |img: &[u8], x: usize, y: usize| img[(y * w + x) * 4..(y * w + x) * 4 + 3].to_vec();
assert_eq!(px(&frames[0], 150, 100), vec![30, 220, 40], "the line keeps its colour");
let red = px(&frames[0], 60, 60);
assert!(red[0] > 180 && red[1] < 60 && red[2] < 60, "the part stays red: {red:?}");
// Elsewhere each channel is close to the original.
let worst = f.chunks(4).zip(frames[0].chunks(4)).map(|(a, b)| (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap()).max().unwrap();
assert!(worst <= 12, "worst channel error {worst}");
}
#[test]
fn bad_input_is_none_not_a_crash() {
assert!(gif(4, 4, &[], 5).is_none());
assert!(gif(4, 4, &[vec![0; 63]], 5).is_none());
assert!(gif(0, 4, &[vec![]], 5).is_none());
assert!(gif(70_000, 1, &[vec![0; 280_000]], 5).is_none());
assert!(gif(usize::MAX, 2, &[vec![0; 8]], 5).is_none());
}
}
+2
View File
@@ -8,6 +8,7 @@ mod analysis;
mod backend;
mod camera;
mod curve_display;
mod gif;
pub mod job;
pub mod lighting;
mod product;
@@ -28,6 +29,7 @@ pub use backend::{
};
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped};
pub use gif::gif;
pub use product::{FaceAppearance, ProductMesh, ProductScene};
pub use raster::{Canvas, render, render_png, render_transparent};
pub use scene::{Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png};