Add animated GIF support to aster-webui
Build And Push Container / build-and-push (push) Successful in 59s
Build And Push Container / build-and-push (push) Successful in 59s
This commit is contained in:
+385
-92
@@ -1,4 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
io::Cursor,
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{Arc, OnceLock, RwLock},
|
sync::{Arc, OnceLock, RwLock},
|
||||||
@@ -20,7 +21,10 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use chrono::{Local, Utc};
|
use chrono::{Local, Utc};
|
||||||
use clap::Parser;
|
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::{
|
use imageproc::{
|
||||||
drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut},
|
drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut},
|
||||||
rect::Rect,
|
rect::Rect,
|
||||||
@@ -35,6 +39,8 @@ const DISPLAY_WIDTH: u32 = 960;
|
|||||||
const DISPLAY_HEIGHT: u32 = 376;
|
const DISPLAY_HEIGHT: u32 = 376;
|
||||||
const SYSTEM_FRAME_NAME: &str = "System Specs";
|
const SYSTEM_FRAME_NAME: &str = "System Specs";
|
||||||
const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif");
|
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)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version, about)]
|
#[command(author, version, about)]
|
||||||
@@ -82,25 +88,36 @@ struct RotationSnapshot {
|
|||||||
|
|
||||||
impl RotationSnapshot {
|
impl RotationSnapshot {
|
||||||
fn rotation_active(&self) -> bool {
|
fn rotation_active(&self) -> bool {
|
||||||
self.custom_panel && !self.frames().is_empty()
|
self.custom_panel && (self.specs_enabled || (self.memes_enabled && !self.active_images.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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
enum RotationFrame {
|
struct GifFrameMeta {
|
||||||
SystemSpecs,
|
name: String,
|
||||||
Image(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)]
|
#[derive(Clone, Serialize)]
|
||||||
@@ -125,8 +142,11 @@ struct SetupView {
|
|||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
struct ImageView {
|
struct ImageView {
|
||||||
name: String,
|
name: String,
|
||||||
|
label: String,
|
||||||
size: u64,
|
size: u64,
|
||||||
url: String,
|
url: String,
|
||||||
|
animated: bool,
|
||||||
|
frame_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize)]
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
@@ -472,13 +492,13 @@ async fn api_delete(
|
|||||||
return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
|
return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = state.image_dir.join(&payload.name);
|
if let Err(err) = delete_image_asset(&state, &payload.name).await {
|
||||||
match fs::remove_file(&path).await {
|
return match err.downcast_ref::<std::io::Error>() {
|
||||||
Ok(_) => {}
|
Some(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
error_response(StatusCode::NOT_FOUND, "Image not found")
|
||||||
return error_response(StatusCode::NOT_FOUND, "Image not found");
|
}
|
||||||
}
|
_ => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
||||||
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut monitor = match load_monitor_json(&state).await {
|
let mut monitor = match load_monitor_json(&state).await {
|
||||||
@@ -716,12 +736,32 @@ async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
|
|||||||
let Some(name) = path.file_name().map(|name| name.to_string_lossy().to_string()) else {
|
let Some(name) = path.file_name().map(|name| name.to_string_lossy().to_string()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if is_gif_frame_name(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let meta = entry.metadata().await?;
|
let meta = entry.metadata().await?;
|
||||||
out.push(ImageView {
|
if is_gif_manifest_name(&name) {
|
||||||
url: format!("/api/images/{name}"),
|
let raw = fs::read_to_string(&path).await?;
|
||||||
name,
|
let manifest: GifSetManifest = serde_json::from_str(&raw)
|
||||||
size: meta.len(),
|
.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));
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
Ok(out)
|
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> {
|
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)
|
let image = image::load_from_memory(bytes)
|
||||||
.with_context(|| format!("Unsupported image format: {file_name}"))?;
|
.with_context(|| format!("Unsupported image format: {file_name}"))?;
|
||||||
let target_name = make_image_name(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)
|
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 {
|
fn render_display_panel(image: &DynamicImage) -> RgbImage {
|
||||||
let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
|
let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
|
||||||
let rgb = resized.to_rgb8();
|
let rgb = resized.to_rgb8();
|
||||||
@@ -1052,6 +1143,12 @@ fn display_font() -> &'static FontArc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn make_image_name(file_name: &str) -> String {
|
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
|
let base = file_name
|
||||||
.rsplit_once('.')
|
.rsplit_once('.')
|
||||||
.map(|(name, _)| name)
|
.map(|(name, _)| name)
|
||||||
@@ -1064,9 +1161,181 @@ fn make_image_name(file_name: &str) -> String {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let clean = clean.trim_matches('-');
|
let clean = clean.trim_matches('-');
|
||||||
let clean = if clean.is_empty() { "panel" } else { clean };
|
if clean.is_empty() {
|
||||||
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
|
"panel".into()
|
||||||
format!("panel-{timestamp}-{clean}.jpg")
|
} 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) {
|
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<()> {
|
fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
||||||
let mut snapshot = RotationSnapshot::default();
|
let mut snapshot = RotationSnapshot::default();
|
||||||
let mut cycle_started_at = Instant::now();
|
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 {
|
loop {
|
||||||
let monitor = match load_monitor_json_sync(&state.monitor_path) {
|
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 {
|
if new_snapshot != snapshot {
|
||||||
snapshot = new_snapshot;
|
snapshot = new_snapshot;
|
||||||
cycle_started_at = Instant::now();
|
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| {
|
update_display_status(&state.display_status, |status| {
|
||||||
status.custom_panel = snapshot.custom_panel;
|
status.custom_panel = snapshot.custom_panel;
|
||||||
status.specs_enabled = snapshot.specs_enabled;
|
status.specs_enabled = snapshot.specs_enabled;
|
||||||
@@ -1185,16 +1454,13 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let switch_after = Duration::from_secs(snapshot.switch_time as u64);
|
let specs_only = snapshot.specs_enabled && (!snapshot.memes_enabled || snapshot.active_images.is_empty());
|
||||||
let elapsed = cycle_started_at.elapsed();
|
if let Some((_slot_idx, active_frame)) =
|
||||||
let frame_slot = elapsed.as_secs() / snapshot.switch_time as u64;
|
current_slot_frame(&slots, specs_only, snapshot.switch_time, cycle_started_at)
|
||||||
let next_index = (frame_slot as usize) % frames.len();
|
{
|
||||||
let send_due = current_frame_index != Some(next_index);
|
let send_due = current_frame_key.as_deref() != Some(active_frame.key.as_str());
|
||||||
|
if send_due {
|
||||||
if send_due {
|
if active_frame.image_name == SYSTEM_FRAME_NAME {
|
||||||
let frame = frames[next_index].clone();
|
|
||||||
match frame {
|
|
||||||
RotationFrame::SystemSpecs => {
|
|
||||||
let panel_name = SYSTEM_FRAME_NAME.to_string();
|
let panel_name = SYSTEM_FRAME_NAME.to_string();
|
||||||
let rgb_img = render_system_panel();
|
let rgb_img = render_system_panel();
|
||||||
screen
|
screen
|
||||||
@@ -1207,40 +1473,44 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
|||||||
status.connected = true;
|
status.connected = true;
|
||||||
status.updated_at = Some(stamp_now());
|
status.updated_at = Some(stamp_now());
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
RotationFrame::Image(image_name) => match load_panel_rgb(&state.image_dir, &image_name)
|
match load_panel_rgb(&state.image_dir, &active_frame.image_name) {
|
||||||
{
|
Ok(mut rgb_img) => {
|
||||||
Ok(mut rgb_img) => {
|
if snapshot.specs_enabled {
|
||||||
if snapshot.specs_enabled {
|
overlay_system_specs(&mut rgb_img);
|
||||||
overlay_system_specs(&mut rgb_img);
|
}
|
||||||
}
|
screen
|
||||||
screen
|
.send_image(&rgb_img)
|
||||||
.send_image(&rgb_img)
|
.with_context(|| {
|
||||||
.with_context(|| format!("Failed to send panel {image_name}"))?;
|
format!("Failed to send panel {}", active_frame.image_name)
|
||||||
|
})?;
|
||||||
|
|
||||||
update_display_status(&state.display_status, |status| {
|
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.last_error = None;
|
||||||
status.connected = true;
|
status.connected = true;
|
||||||
status.updated_at = Some(stamp_now());
|
status.updated_at = Some(stamp_now());
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Skipping panel {}: {err}", active_frame.image_name);
|
||||||
|
update_display_status(&state.display_status, |status| {
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
}
|
||||||
warn!("Skipping panel {image_name}: {err}");
|
current_frame_key = Some(active_frame.key);
|
||||||
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.connected = true;
|
|
||||||
status.updated_at = Some(stamp_now());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
current_frame_index = Some(next_index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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())
|
Ok(image.to_rgb8())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rotation_sleep(frame_count: usize, switch_after: Duration, cycle_started_at: Instant) -> Duration {
|
fn rotation_sleep(rotation_active: bool, slots: &[RotationSlot], cycle_started_at: Instant) -> Duration {
|
||||||
if frame_count <= 1 {
|
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);
|
return Duration::from_millis(750);
|
||||||
}
|
}
|
||||||
|
|
||||||
let elapsed = cycle_started_at.elapsed();
|
let cycle_ms: u64 = slots
|
||||||
let switch_secs = switch_after.as_secs().max(1);
|
.iter()
|
||||||
let next_boundary_secs = ((elapsed.as_secs() / switch_secs) + 1) * switch_secs;
|
.map(|slot| slot.total_duration.as_millis() as u64)
|
||||||
let remaining = Duration::from_secs(next_boundary_secs).saturating_sub(elapsed);
|
.sum::<u64>()
|
||||||
if remaining > Duration::from_millis(750) {
|
.max(100);
|
||||||
Duration::from_millis(750)
|
let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms;
|
||||||
} else if remaining < Duration::from_millis(200) {
|
let mut cursor = 0u64;
|
||||||
Duration::from_millis(200)
|
|
||||||
} else {
|
for slot in slots {
|
||||||
remaining
|
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 {
|
fn read_display_status(status: &Arc<RwLock<DisplayStatus>>) -> DisplayStatus {
|
||||||
@@ -1455,7 +1744,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="muted">aoostar-rs fork</p>
|
<p class="muted">aoostar-rs fork</p>
|
||||||
<h1>aster-webui</h1>
|
<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">
|
<div class="controls">
|
||||||
<label>
|
<label>
|
||||||
<span>Switch Time</span>
|
<span>Switch Time</span>
|
||||||
@@ -1672,6 +1961,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
const imageMap = new Map(state.images.map((image) => [image.name, image]));
|
||||||
el.gallery.innerHTML = "";
|
el.gallery.innerHTML = "";
|
||||||
for (const image of state.images) {
|
for (const image of state.images) {
|
||||||
const selected = state.activeImages.includes(image.name);
|
const selected = state.activeImages.includes(image.name);
|
||||||
@@ -1680,8 +1970,8 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
tile.innerHTML = `
|
tile.innerHTML = `
|
||||||
<img src="${image.url}" alt="${image.name}">
|
<img src="${image.url}" alt="${image.name}">
|
||||||
<div class="tile-body">
|
<div class="tile-body">
|
||||||
<div class="tile-title">${image.name}</div>
|
<div class="tile-title">${image.label}</div>
|
||||||
<div class="muted">${bytes(image.size)}</div>
|
<div class="muted">${bytes(image.size)}${image.animated ? ` • GIF • ${image.frame_count} Frames` : ""}</div>
|
||||||
<div class="mini">
|
<div class="mini">
|
||||||
<button class="ghost" type="button">${selected ? "Remove" : "Add"}</button>
|
<button class="ghost" type="button">${selected ? "Remove" : "Add"}</button>
|
||||||
<button class="danger" type="button">Delete</button>
|
<button class="danger" type="button">Delete</button>
|
||||||
@@ -1728,13 +2018,16 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.activeImages.forEach((name, index) => {
|
state.activeImages.forEach((name, index) => {
|
||||||
|
const image = imageMap.get(name);
|
||||||
|
const label = image ? image.label : name;
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "order-item";
|
row.className = "order-item";
|
||||||
|
const isCurrent = state.display.current_image === name || state.display.current_image === label;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="index">${index + 1}</div>
|
<div class="index">${index + 1}</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<div>${name}</div>
|
<div>${label}</div>
|
||||||
<div class="muted">${state.display.current_image === name ? "Currently shown" : ""}</div>
|
<div class="muted">${isCurrent ? "Currently shown" : (image && image.animated ? `Animated • ${image.frame_count} Frames` : "")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mini">
|
<div class="mini">
|
||||||
<button class="ghost" type="button">Up</button>
|
<button class="ghost" type="button">Up</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user