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::{
|
||||
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");
|
||||
}
|
||||
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
||||
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")
|
||||
}
|
||||
_ => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
if is_gif_frame_name(&name) {
|
||||
continue;
|
||||
}
|
||||
let meta = entry.metadata().await?;
|
||||
out.push(ImageView {
|
||||
url: format!("/api/images/{name}"),
|
||||
name,
|
||||
size: meta.len(),
|
||||
});
|
||||
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);
|
||||
|
||||
if send_due {
|
||||
let frame = frames[next_index].clone();
|
||||
match frame {
|
||||
RotationFrame::SystemSpecs => {
|
||||
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 {
|
||||
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)
|
||||
{
|
||||
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}"))?;
|
||||
} 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 {}", active_frame.image_name)
|
||||
})?;
|
||||
|
||||
update_display_status(&state.display_status, |status| {
|
||||
status.current_image = Some(image_name.clone());
|
||||
status.last_error = None;
|
||||
status.connected = true;
|
||||
status.updated_at = Some(stamp_now());
|
||||
});
|
||||
update_display_status(&state.display_status, |status| {
|
||||
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 {}: {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}");
|
||||
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_key = Some(active_frame.key);
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user