Add animated GIF support to aster-webui
Build And Push Container / build-and-push (push) Successful in 59s

This commit is contained in:
2026-06-19 20:00:16 +02:00
parent 344e96fc5d
commit 3bcd5fe618
+361 -68
View File
@@ -1,4 +1,5 @@
use std::{
io::Cursor,
net::SocketAddr,
path::PathBuf,
sync::{Arc, OnceLock, RwLock},
@@ -20,7 +21,10 @@ use axum::{
};
use chrono::{Local, Utc};
use clap::Parser;
use image::{DynamicImage, ImageFormat, Rgb, RgbImage, imageops::FilterType};
use image::{
AnimationDecoder, DynamicImage, Frame, ImageFormat, Rgb, RgbImage, codecs::gif::GifDecoder,
imageops::FilterType,
};
use imageproc::{
drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut},
rect::Rect,
@@ -35,6 +39,8 @@ const DISPLAY_WIDTH: u32 = 960;
const DISPLAY_HEIGHT: u32 = 376;
const SYSTEM_FRAME_NAME: &str = "System Specs";
const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif");
const GIF_FRAME_PREFIX: &str = ".gifframe-";
const GIF_MANIFEST_SUFFIX: &str = ".gifset.json";
#[derive(Parser, Debug)]
#[command(author, version, about)]
@@ -82,25 +88,36 @@ struct RotationSnapshot {
impl RotationSnapshot {
fn rotation_active(&self) -> bool {
self.custom_panel && !self.frames().is_empty()
}
fn frames(&self) -> Vec<RotationFrame> {
let mut frames = Vec::new();
if self.memes_enabled {
frames.extend(self.active_images.iter().cloned().map(RotationFrame::Image));
}
if frames.is_empty() && self.specs_enabled {
frames.push(RotationFrame::SystemSpecs);
}
frames
self.custom_panel && (self.specs_enabled || (self.memes_enabled && !self.active_images.is_empty()))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum RotationFrame {
SystemSpecs,
Image(String),
#[derive(Clone, Debug, Serialize, Deserialize)]
struct GifFrameMeta {
name: String,
delay_ms: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct GifSetManifest {
kind: String,
version: u32,
label: String,
preview: String,
frames: Vec<GifFrameMeta>,
}
#[derive(Clone, Debug)]
struct ActiveFrame {
key: String,
image_name: String,
display_name: String,
}
#[derive(Clone, Debug)]
struct RotationSlot {
total_duration: Duration,
frames: Vec<(ActiveFrame, Duration)>,
}
#[derive(Clone, Serialize)]
@@ -125,8 +142,11 @@ struct SetupView {
#[derive(Clone, Serialize)]
struct ImageView {
name: String,
label: String,
size: u64,
url: String,
animated: bool,
frame_count: usize,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -472,13 +492,13 @@ async fn api_delete(
return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
}
let path = state.image_dir.join(&payload.name);
match fs::remove_file(&path).await {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return error_response(StatusCode::NOT_FOUND, "Image not found");
if let Err(err) = delete_image_asset(&state, &payload.name).await {
return match err.downcast_ref::<std::io::Error>() {
Some(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
error_response(StatusCode::NOT_FOUND, "Image not found")
}
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
_ => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
};
}
let mut monitor = match load_monitor_json(&state).await {
@@ -716,13 +736,33 @@ async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
let Some(name) = path.file_name().map(|name| name.to_string_lossy().to_string()) else {
continue;
};
if is_gif_frame_name(&name) {
continue;
}
let meta = entry.metadata().await?;
if is_gif_manifest_name(&name) {
let raw = fs::read_to_string(&path).await?;
let manifest: GifSetManifest = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?;
out.push(ImageView {
url: format!("/api/images/{}", manifest.preview),
name,
label: manifest.label,
size: meta.len(),
animated: true,
frame_count: manifest.frames.len(),
});
} else {
out.push(ImageView {
url: format!("/api/images/{name}"),
label: name.clone(),
name,
size: meta.len(),
animated: false,
frame_count: 1,
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
@@ -821,6 +861,10 @@ fn set_custom_panels(
}
async fn convert_and_store_image(state: &AppState, file_name: &str, bytes: &[u8]) -> Result<String> {
if is_gif_file(file_name, bytes) {
return convert_and_store_gif(state, file_name, bytes).await;
}
let image = image::load_from_memory(bytes)
.with_context(|| format!("Unsupported image format: {file_name}"))?;
let target_name = make_image_name(file_name);
@@ -832,6 +876,53 @@ async fn convert_and_store_image(state: &AppState, file_name: &str, bytes: &[u8]
Ok(target_name)
}
async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8]) -> Result<String> {
let decoder = GifDecoder::new(Cursor::new(bytes))
.with_context(|| format!("Unsupported GIF format: {file_name}"))?;
let frames = decoder
.into_frames()
.collect_frames()
.context("Failed to decode GIF frames")?;
if frames.is_empty() {
anyhow::bail!("GIF contains no frames");
}
let base = make_image_stem(file_name);
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
let manifest_name = format!("panel-{timestamp}-{base}{GIF_MANIFEST_SUFFIX}");
let mut stored_frames = Vec::new();
for (idx, frame) in frames.into_iter().enumerate() {
let delay_ms = frame_delay_ms(&frame);
let frame_name = format!("{GIF_FRAME_PREFIX}{timestamp}-{base}-{idx:03}.jpg");
let frame_path = state.image_dir.join(&frame_name);
let rendered = render_display_panel(&DynamicImage::ImageRgba8(frame.into_buffer()));
rendered
.save_with_format(&frame_path, ImageFormat::Jpeg)
.with_context(|| format!("Failed to save {}", frame_path.display()))?;
stored_frames.push(GifFrameMeta {
name: frame_name,
delay_ms,
});
}
let manifest = GifSetManifest {
kind: "gif_set".into(),
version: 1,
label: file_name.to_string(),
preview: stored_frames
.first()
.map(|frame| frame.name.clone())
.unwrap_or_default(),
frames: stored_frames,
};
let manifest_path = state.image_dir.join(&manifest_name);
fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)
.await
.with_context(|| format!("Failed to save {}", manifest_path.display()))?;
Ok(manifest_name)
}
fn render_display_panel(image: &DynamicImage) -> RgbImage {
let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
let rgb = resized.to_rgb8();
@@ -1052,6 +1143,12 @@ fn display_font() -> &'static FontArc {
}
fn make_image_name(file_name: &str) -> String {
let clean = make_image_stem(file_name);
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
format!("panel-{timestamp}-{clean}.jpg")
}
fn make_image_stem(file_name: &str) -> String {
let base = file_name
.rsplit_once('.')
.map(|(name, _)| name)
@@ -1064,9 +1161,181 @@ fn make_image_name(file_name: &str) -> String {
})
.collect();
let clean = clean.trim_matches('-');
let clean = if clean.is_empty() { "panel" } else { clean };
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
format!("panel-{timestamp}-{clean}.jpg")
if clean.is_empty() {
"panel".into()
} else {
clean.to_string()
}
}
fn is_gif_file(file_name: &str, bytes: &[u8]) -> bool {
file_name.to_ascii_lowercase().ends_with(".gif") || bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
}
fn is_gif_manifest_name(name: &str) -> bool {
name.ends_with(GIF_MANIFEST_SUFFIX)
}
fn is_gif_frame_name(name: &str) -> bool {
name.starts_with(GIF_FRAME_PREFIX)
}
fn frame_delay_ms(frame: &Frame) -> u32 {
let (numer, denom) = frame.delay().numer_denom_ms();
let delay = if denom == 0 {
numer
} else {
((numer as f64) / (denom as f64)).round() as u32
};
delay.max(100)
}
async fn delete_image_asset(state: &AppState, name: &str) -> Result<()> {
let path = state.image_dir.join(name);
if is_gif_manifest_name(name) {
let raw = fs::read_to_string(&path)
.await
.with_context(|| format!("Failed to read {}", path.display()))?;
let manifest: GifSetManifest = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?;
for frame in manifest.frames {
let frame_path = state.image_dir.join(frame.name);
match fs::remove_file(&frame_path).await {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
}
}
fs::remove_file(&path)
.await
.with_context(|| format!("Failed to remove {}", path.display()))?;
Ok(())
}
fn load_gif_manifest(image_dir: &PathBuf, name: &str) -> Result<GifSetManifest> {
let path = image_dir.join(name);
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read GIF manifest {}", path.display()))?;
serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse GIF manifest {}", path.display()))
}
fn build_rotation_slots(snapshot: &RotationSnapshot, image_dir: &PathBuf) -> Vec<RotationSlot> {
let mut slots = Vec::new();
if snapshot.memes_enabled {
for name in &snapshot.active_images {
if is_gif_manifest_name(name) {
match load_gif_manifest(image_dir, name) {
Ok(manifest) if !manifest.frames.is_empty() => {
let mut total_ms = 0u64;
let mut frames = Vec::new();
for (idx, frame) in manifest.frames.iter().enumerate() {
total_ms += frame.delay_ms.max(100) as u64;
frames.push((
ActiveFrame {
key: format!("{name}#{idx}"),
image_name: frame.name.clone(),
display_name: manifest.label.clone(),
},
Duration::from_millis(frame.delay_ms.max(100) as u64),
));
}
slots.push(RotationSlot {
total_duration: Duration::from_millis(total_ms.max(100)),
frames,
});
}
Ok(_) => {}
Err(err) => warn!("Skipping GIF set {name}: {err}"),
}
} else {
slots.push(RotationSlot {
total_duration: Duration::from_secs(snapshot.switch_time as u64),
frames: vec![(
ActiveFrame {
key: name.clone(),
image_name: name.clone(),
display_name: name.clone(),
},
Duration::from_secs(snapshot.switch_time as u64),
)],
});
}
}
}
if slots.is_empty() && snapshot.specs_enabled {
slots.push(RotationSlot {
total_duration: Duration::from_secs(snapshot.switch_time as u64),
frames: vec![(
ActiveFrame {
key: SYSTEM_FRAME_NAME.into(),
image_name: SYSTEM_FRAME_NAME.into(),
display_name: SYSTEM_FRAME_NAME.into(),
},
Duration::from_secs(snapshot.switch_time as u64),
)],
});
}
slots
}
fn current_slot_frame(
slots: &[RotationSlot],
specs_only: bool,
switch_time: u32,
cycle_started_at: Instant,
) -> Option<(usize, ActiveFrame)> {
if specs_only {
return Some((
0,
ActiveFrame {
key: SYSTEM_FRAME_NAME.into(),
image_name: SYSTEM_FRAME_NAME.into(),
display_name: SYSTEM_FRAME_NAME.into(),
},
));
}
if slots.is_empty() {
return None;
}
let cycle_ms: u64 = slots
.iter()
.map(|slot| slot.total_duration.as_millis() as u64)
.sum::<u64>()
.max((switch_time as u64).max(1) * 1000);
let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms;
let mut cursor = 0u64;
for (slot_idx, slot) in slots.iter().enumerate() {
let slot_ms = slot.total_duration.as_millis() as u64;
if elapsed_ms < cursor + slot_ms {
if slot.frames.len() == 1 {
return Some((slot_idx, slot.frames[0].0.clone()));
}
let local_ms = elapsed_ms - cursor;
let mut frame_cursor = 0u64;
for (frame, delay) in &slot.frames {
let delay_ms = delay.as_millis() as u64;
if local_ms < frame_cursor + delay_ms {
return Some((slot_idx, frame.clone()));
}
frame_cursor += delay_ms;
}
return slot
.frames
.last()
.map(|(frame, _)| (slot_idx, frame.clone()));
}
cursor += slot_ms;
}
slots
.last()
.and_then(|slot| slot.frames.last().map(|(frame, _)| frame.clone()))
.map(|frame| (slots.len() - 1, frame))
}
fn spawn_display_worker(state: Arc<AppState>, config: DisplayConfig) {
@@ -1141,7 +1410,8 @@ fn open_screen(config: &DisplayConfig) -> Result<AooScreen> {
fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
let mut snapshot = RotationSnapshot::default();
let mut cycle_started_at = Instant::now();
let mut current_frame_index = None;
let mut current_frame_key: Option<String> = None;
let mut slots: Vec<RotationSlot> = Vec::new();
loop {
let monitor = match load_monitor_json_sync(&state.monitor_path) {
@@ -1161,11 +1431,10 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
if new_snapshot != snapshot {
snapshot = new_snapshot;
cycle_started_at = Instant::now();
current_frame_index = None;
current_frame_key = None;
slots = build_rotation_slots(&snapshot, &state.image_dir);
}
let frames = snapshot.frames();
update_display_status(&state.display_status, |status| {
status.custom_panel = snapshot.custom_panel;
status.specs_enabled = snapshot.specs_enabled;
@@ -1185,16 +1454,13 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
continue;
}
let switch_after = Duration::from_secs(snapshot.switch_time as u64);
let elapsed = cycle_started_at.elapsed();
let frame_slot = elapsed.as_secs() / snapshot.switch_time as u64;
let next_index = (frame_slot as usize) % frames.len();
let send_due = current_frame_index != Some(next_index);
let specs_only = snapshot.specs_enabled && (!snapshot.memes_enabled || snapshot.active_images.is_empty());
if let Some((_slot_idx, active_frame)) =
current_slot_frame(&slots, specs_only, snapshot.switch_time, cycle_started_at)
{
let send_due = current_frame_key.as_deref() != Some(active_frame.key.as_str());
if send_due {
let frame = frames[next_index].clone();
match frame {
RotationFrame::SystemSpecs => {
if active_frame.image_name == SYSTEM_FRAME_NAME {
let panel_name = SYSTEM_FRAME_NAME.to_string();
let rgb_img = render_system_panel();
screen
@@ -1207,40 +1473,44 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
status.connected = true;
status.updated_at = Some(stamp_now());
});
}
RotationFrame::Image(image_name) => match load_panel_rgb(&state.image_dir, &image_name)
{
} else {
match load_panel_rgb(&state.image_dir, &active_frame.image_name) {
Ok(mut rgb_img) => {
if snapshot.specs_enabled {
overlay_system_specs(&mut rgb_img);
}
screen
.send_image(&rgb_img)
.with_context(|| format!("Failed to send panel {image_name}"))?;
.with_context(|| {
format!("Failed to send panel {}", active_frame.image_name)
})?;
update_display_status(&state.display_status, |status| {
status.current_image = Some(image_name.clone());
status.current_image = Some(active_frame.display_name.clone());
status.last_error = None;
status.connected = true;
status.updated_at = Some(stamp_now());
});
}
Err(err) => {
warn!("Skipping panel {image_name}: {err}");
warn!("Skipping panel {}: {err}", active_frame.image_name);
update_display_status(&state.display_status, |status| {
status.current_image = Some(image_name.clone());
status.last_error =
Some(format!("Panel {image_name} could not be rendered: {err}"));
status.current_image = Some(active_frame.display_name.clone());
status.last_error = Some(format!(
"Panel {} could not be rendered: {err}",
active_frame.display_name
));
status.connected = true;
status.updated_at = Some(stamp_now());
});
}
},
}
current_frame_index = Some(next_index);
}
current_frame_key = Some(active_frame.key);
}
}
thread::sleep(rotation_sleep(frames.len(), switch_after, cycle_started_at));
thread::sleep(rotation_sleep(snapshot.rotation_active(), &slots, cycle_started_at));
}
}
@@ -1257,22 +1527,41 @@ fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result<RgbImage> {
Ok(image.to_rgb8())
}
fn rotation_sleep(frame_count: usize, switch_after: Duration, cycle_started_at: Instant) -> Duration {
if frame_count <= 1 {
fn rotation_sleep(rotation_active: bool, slots: &[RotationSlot], cycle_started_at: Instant) -> Duration {
if !rotation_active || slots.is_empty() {
return Duration::from_millis(750);
}
let total_frames: usize = slots.iter().map(|slot| slot.frames.len()).sum();
if total_frames <= 1 {
return Duration::from_millis(750);
}
let elapsed = cycle_started_at.elapsed();
let switch_secs = switch_after.as_secs().max(1);
let next_boundary_secs = ((elapsed.as_secs() / switch_secs) + 1) * switch_secs;
let remaining = Duration::from_secs(next_boundary_secs).saturating_sub(elapsed);
if remaining > Duration::from_millis(750) {
Duration::from_millis(750)
} else if remaining < Duration::from_millis(200) {
Duration::from_millis(200)
} else {
remaining
let cycle_ms: u64 = slots
.iter()
.map(|slot| slot.total_duration.as_millis() as u64)
.sum::<u64>()
.max(100);
let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms;
let mut cursor = 0u64;
for slot in slots {
let slot_ms = slot.total_duration.as_millis() as u64;
if elapsed_ms < cursor + slot_ms {
let local_ms = elapsed_ms - cursor;
let mut frame_cursor = 0u64;
for (_, delay) in &slot.frames {
let delay_ms = delay.as_millis() as u64;
if local_ms < frame_cursor + delay_ms {
let remaining = frame_cursor + delay_ms - local_ms;
return Duration::from_millis(remaining.clamp(100, 750));
}
frame_cursor += delay_ms;
}
}
cursor += slot_ms;
}
Duration::from_millis(250)
}
fn read_display_status(status: &Arc<RwLock<DisplayStatus>>) -> DisplayStatus {
@@ -1455,7 +1744,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<div class="card">
<p class="muted">aoostar-rs fork</p>
<h1>aster-webui</h1>
<p>Eigene Rust-WebUI mit nativer Display-Logik. Uploads werden auf 960x376 JPG normalisiert; die Rotation laeuft direkt ueber `aoostar-rs` ohne Vendor-Binary.</p>
<p>Eigene Rust-WebUI mit nativer Display-Logik. Bilder werden auf 960x376 normalisiert, GIFs als animierte Frame-Sets importiert; die Rotation laeuft direkt ueber `aoostar-rs` ohne Vendor-Binary.</p>
<div class="controls">
<label>
<span>Switch Time</span>
@@ -1672,6 +1961,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
}
function render() {
const imageMap = new Map(state.images.map((image) => [image.name, image]));
el.gallery.innerHTML = "";
for (const image of state.images) {
const selected = state.activeImages.includes(image.name);
@@ -1680,8 +1970,8 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
tile.innerHTML = `
<img src="${image.url}" alt="${image.name}">
<div class="tile-body">
<div class="tile-title">${image.name}</div>
<div class="muted">${bytes(image.size)}</div>
<div class="tile-title">${image.label}</div>
<div class="muted">${bytes(image.size)}${image.animated ? ` • GIF • ${image.frame_count} Frames` : ""}</div>
<div class="mini">
<button class="ghost" type="button">${selected ? "Remove" : "Add"}</button>
<button class="danger" type="button">Delete</button>
@@ -1728,13 +2018,16 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
return;
}
state.activeImages.forEach((name, index) => {
const image = imageMap.get(name);
const label = image ? image.label : name;
const row = document.createElement("div");
row.className = "order-item";
const isCurrent = state.display.current_image === name || state.display.current_image === label;
row.innerHTML = `
<div class="index">${index + 1}</div>
<div class="meta">
<div>${name}</div>
<div class="muted">${state.display.current_image === name ? "Currently shown" : ""}</div>
<div>${label}</div>
<div class="muted">${isCurrent ? "Currently shown" : (image && image.animated ? `Animated • ${image.frame_count} Frames` : "")}</div>
</div>
<div class="mini">
<button class="ghost" type="button">Up</button>