feat(image): add image dimensions cache with header-only reading
This commit is contained in:
parent
6e2e261156
commit
45d14e2ad9
@ -33,7 +33,7 @@ syntect = { version = "5", default-features = false, features = ["default-syntax
|
|||||||
# Do NOT add "webp" to the features list without updating src/webp.rs.
|
# Do NOT add "webp" to the features list without updating src/webp.rs.
|
||||||
image = { version = "0.25", optional = true, default-features = false, features = ["jpeg", "png", "gif"] }
|
image = { version = "0.25", optional = true, default-features = false, features = ["jpeg", "png", "gif"] }
|
||||||
zenwebp = { version = "0.3", optional = true }
|
zenwebp = { version = "0.3", optional = true }
|
||||||
moka = { version = "0.12", features = ["future"], optional = true }
|
moka = { version = "0.12", features = ["future", "sync"], optional = true }
|
||||||
governor = { version = "0.8", optional = true }
|
governor = { version = "0.8", optional = true }
|
||||||
md-5 = { version = "0.10", optional = true }
|
md-5 = { version = "0.10", optional = true }
|
||||||
futures = { version = "0.3", optional = true }
|
futures = { version = "0.3", optional = true }
|
||||||
|
|||||||
@ -16,6 +16,8 @@ use std::net::SocketAddr;
|
|||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use moka::future::Cache;
|
use moka::future::Cache;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
use moka::sync::Cache as SyncCache;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
@ -560,10 +562,88 @@ pub async fn serve_image(
|
|||||||
image_response(processed, content_type, "public, max-age=86400", &headers)
|
image_response(processed, content_type, "public, max-age=86400", &headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 图片尺寸缓存(moka sync)。key = 相对路径如 "2026/06/22/x.webp"。
|
||||||
|
/// 用 sync cache 而非 future cache:render_markdown_enhanced 是同步函数,不能 .await。
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
static IMAGE_DIMENSIONS_CACHE: LazyLock<SyncCache<String, (u32, u32)>> = LazyLock::new(|| {
|
||||||
|
let ttl = std::env::var("IMAGE_DIMENSIONS_CACHE_TTL_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.map(std::time::Duration::from_secs)
|
||||||
|
.unwrap_or(std::time::Duration::from_secs(86400)); // 默认 24h
|
||||||
|
SyncCache::builder().time_to_live(ttl).build()
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 读取图片真实尺寸(只读 header,不解码像素)。
|
||||||
|
///
|
||||||
|
/// - `rel_path`:相对路径如 "2026/06/22/x.webp"(不含 /uploads/ 前缀和 query)
|
||||||
|
/// - 优先查缓存;miss 时读文件、解析 header、写入缓存
|
||||||
|
/// - 失败返回 None(调用方回退到不设 aspect-ratio)
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub fn get_image_dimensions(rel_path: &str) -> Option<(u32, u32)> {
|
||||||
|
if let Some(dims) = IMAGE_DIMENSIONS_CACHE.get(rel_path) {
|
||||||
|
return Some(dims);
|
||||||
|
}
|
||||||
|
let full_path = std::path::Path::new("uploads").join(rel_path);
|
||||||
|
let data = std::fs::read(&full_path).ok()?;
|
||||||
|
let dims = read_dimensions_from_bytes(&data, rel_path)?;
|
||||||
|
IMAGE_DIMENSIONS_CACHE.insert(rel_path.to_string(), dims);
|
||||||
|
Some(dims)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按扩展名分发:webp 走 zenwebp header,gif/png/jpeg 走 image crate。
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn read_dimensions_from_bytes(data: &[u8], path: &str) -> Option<(u32, u32)> {
|
||||||
|
let ext = std::path::Path::new(path)
|
||||||
|
.extension()?
|
||||||
|
.to_str()?
|
||||||
|
.to_lowercase();
|
||||||
|
match ext.as_str() {
|
||||||
|
"webp" => {
|
||||||
|
// zenwebp 的 WebPDecoder::build 只解析 RIFF header,不解码像素
|
||||||
|
let decoder = zenwebp::WebPDecoder::build(data).ok()?;
|
||||||
|
let info = decoder.info();
|
||||||
|
Some((info.width, info.height))
|
||||||
|
}
|
||||||
|
"gif" | "png" | "jpg" | "jpeg" => {
|
||||||
|
// image crate 的 into_dimensions 只读 header
|
||||||
|
let reader = image::ImageReader::new(std::io::Cursor::new(data))
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?;
|
||||||
|
reader.into_dimensions().ok()
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "server"))]
|
#[cfg(all(test, feature = "server"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_webp_dimensions_from_bytes() {
|
||||||
|
// 构造一个 16x9 的 webp
|
||||||
|
let img = image::DynamicImage::new_rgb8(16, 9);
|
||||||
|
let webp_bytes = crate::webp::encode(&img, 85.0, 2).unwrap();
|
||||||
|
let dims = read_dimensions_from_bytes(&webp_bytes, "test.webp");
|
||||||
|
assert_eq!(dims, Some((16, 9)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_png_dimensions_from_bytes() {
|
||||||
|
let img = image::DynamicImage::new_rgb8(32, 24);
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
|
||||||
|
let dims = read_dimensions_from_bytes(&buf.into_inner(), "test.png");
|
||||||
|
assert_eq!(dims, Some((32, 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_dimensions_unknown_extension_returns_none() {
|
||||||
|
let dims = read_dimensions_from_bytes(b"not an image", "test.xyz");
|
||||||
|
assert_eq!(dims, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn image_params_validate_valid_defaults() {
|
fn image_params_validate_valid_defaults() {
|
||||||
let params = ImageParams::default();
|
let params = ImageParams::default();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user