Add animated GIF output with parallelised frame quantization

Render an animated clct_animation.gif alongside the per-step PNGs.
Frames are loaded and colour-quantized in parallel via rayon, then
written sequentially with the gif crate.  Also converts timestamps
to CET/CEST using chrono-tz.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Schuwi
2026-03-06 22:53:49 +01:00
parent 0c73e04959
commit 764f4f6378
3 changed files with 150 additions and 4 deletions

105
Cargo.lock generated
View File

@@ -127,6 +127,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -186,6 +192,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.60" version = "4.5.60"
@@ -233,8 +249,11 @@ dependencies = [
"anyhow", "anyhow",
"bzip2", "bzip2",
"chrono", "chrono",
"chrono-tz",
"clap", "clap",
"font8x8", "font8x8",
"gif",
"image 0.25.9",
"plotters", "plotters",
"rayon", "rayon",
"reqwest", "reqwest",
@@ -438,6 +457,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gif"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -692,7 +721,20 @@ dependencies = [
"color_quant", "color_quant",
"jpeg-decoder", "jpeg-decoder",
"num-traits", "num-traits",
"png", "png 0.17.16",
]
[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
] ]
[[package]] [[package]]
@@ -790,6 +832,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -817,6 +869,24 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -860,7 +930,7 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405" checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405"
dependencies = [ dependencies = [
"image", "image 0.24.9",
"plotters-backend", "plotters-backend",
] ]
@@ -877,6 +947,19 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -904,6 +987,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -1203,6 +1292,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1573,6 +1668,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"

View File

@@ -17,3 +17,6 @@ chrono = { version = "0.4", default-features = false, features = ["std", "cloc
anyhow = "1" anyhow = "1"
spade = "2" spade = "2"
rayon = "1" rayon = "1"
image = { version = "0.25", default-features = false, features = ["png"] }
gif = "0.14"
chrono-tz = "0.10.4"

View File

@@ -139,14 +139,15 @@ fn main() -> Result<()> {
} }
let forecast_dt = dt + chrono::Duration::hours(step as i64); let forecast_dt = dt + chrono::Duration::hours(step as i64);
let forecast_dt = forecast_dt.with_timezone(&chrono_tz::Europe::Berlin);
let title = if step == 0 { let title = if step == 0 {
format!( format!(
"Conditions at {} UTC", "Conditions at {} CET",
forecast_dt.format("%Y-%m-%d %H:%M") forecast_dt.format("%Y-%m-%d %H:%M")
) )
} else { } else {
format!( format!(
"Prediction at {} UTC (+{:02}h)", "Prediction at {} CET (+{:02}h)",
forecast_dt.format("%Y-%m-%d %H:%M"), forecast_dt.format("%Y-%m-%d %H:%M"),
step step
) )
@@ -167,6 +168,47 @@ fn main() -> Result<()> {
eprintln!(" {}", p.display()); eprintln!(" {}", p.display());
} }
if output_paths.len() > 1 {
eprintln!("Building animation...");
let gif_path = cache_dir.join("clct_animation.gif");
create_gif(&output_paths, &gif_path).context("Creating GIF")?;
eprintln!(" GIF: {}", gif_path.display());
}
Ok(())
}
fn create_gif(png_paths: &[PathBuf], out_path: &PathBuf) -> Result<()> {
use gif::{Encoder, Frame, Repeat};
// Load PNGs and quantize to 256-colour palettes in parallel (the bottleneck).
let n = png_paths.len();
let frames: Vec<Frame<'static>> = png_paths
.par_iter()
.enumerate()
.map(|(i, path)| -> Result<Frame<'static>> {
let img = image::open(path)
.with_context(|| format!("Loading PNG {}", path.display()))?
.into_rgba8();
let (width, height) = img.dimensions();
let mut pixels = img.into_raw();
// speed 8: slightly better quality than the default (10)
let mut frame =
Frame::from_rgba_speed(width as u16, height as u16, &mut pixels, 8);
// Pause longer on first and last frame; 1 unit = 10 ms
frame.delay = if i == 0 || i == n - 1 { 200 } else { 40 };
Ok(frame)
})
.collect::<Result<Vec<_>>>()?;
// Write frames sequentially (GIF encoder is not thread-safe).
let file = std::fs::File::create(out_path)?;
let (w, h) = (frames[0].width, frames[0].height);
let mut encoder = Encoder::new(file, w, h, &[])?;
encoder.set_repeat(Repeat::Infinite)?;
for frame in &frames {
encoder.write_frame(frame)?;
}
Ok(()) Ok(())
} }