refactor: split aster-webui main into modules
Build And Push Container / build-and-push (push) Successful in 57s
Build And Push Container / build-and-push (push) Successful in 57s
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
use std::{io::Cursor, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use asterctl::img;
|
||||
use chrono::Local;
|
||||
use image::{
|
||||
codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage, Frame,
|
||||
ImageFormat, RgbImage,
|
||||
};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::{
|
||||
system::{DISPLAY_HEIGHT, DISPLAY_WIDTH},
|
||||
types::{AppState, GifFrameMeta, GifSetManifest, ImageView},
|
||||
};
|
||||
|
||||
pub(crate) const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif");
|
||||
const GIF_FRAME_PREFIX: &str = ".gifframe-";
|
||||
const GIF_MANIFEST_SUFFIX: &str = ".gifset.json";
|
||||
|
||||
pub(crate) async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
|
||||
let mut out = Vec::new();
|
||||
let mut dir = fs::read_dir(&state.image_dir).await?;
|
||||
while let Some(entry) = dir.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let file_type = entry.file_type().await?;
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
pub(crate) 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);
|
||||
let path = state.image_dir.join(&target_name);
|
||||
let rendered = render_display_panel(&image);
|
||||
rendered
|
||||
.save_with_format(&path, ImageFormat::Jpeg)
|
||||
.with_context(|| format!("Failed to save {}", path.display()))?;
|
||||
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)
|
||||
}
|
||||
|
||||
pub(crate) fn render_display_panel(image: &DynamicImage) -> RgbImage {
|
||||
let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
|
||||
let rgb = resized.to_rgb8();
|
||||
|
||||
let mut canvas = RgbImage::from_pixel(DISPLAY_WIDTH, DISPLAY_HEIGHT, image::Rgb([8, 10, 12]));
|
||||
|
||||
let offset_x = ((DISPLAY_WIDTH - rgb.width()) / 2) as i64;
|
||||
let offset_y = ((DISPLAY_HEIGHT - rgb.height()) / 2) as i64;
|
||||
image::imageops::overlay(&mut canvas, &rgb, offset_x, offset_y);
|
||||
canvas
|
||||
}
|
||||
|
||||
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)
|
||||
.unwrap_or(file_name);
|
||||
let clean: String = base
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'a'..='z' | 'A'..='Z' | '0'..='9' => ch.to_ascii_lowercase(),
|
||||
_ => '-',
|
||||
})
|
||||
.collect();
|
||||
let clean = clean.trim_matches('-');
|
||||
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")
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) 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(())
|
||||
}
|
||||
|
||||
pub(crate) 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()))
|
||||
}
|
||||
|
||||
pub(crate) fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result<RgbImage> {
|
||||
let path = image_dir.join(image_name);
|
||||
let image = img::load_image(&path, Some(asterctl_lcd::DISPLAY_SIZE))
|
||||
.with_context(|| format!("Failed to load panel image {}", path.display()))?;
|
||||
Ok(image.to_rgb8())
|
||||
}
|
||||
Reference in New Issue
Block a user