| |
| |
| |
|
|
| use std::path::{Path, PathBuf}; |
|
|
| use image::GenericImageView; |
| use sha1::{Digest, Sha1}; |
|
|
| use crate::base_system::context::Config; |
|
|
| use super::segment_shared::write_atomic; |
|
|
| |
|
|
| pub(crate) fn sha1_hex(input: &str) -> String { |
| let mut hasher = Sha1::new(); |
| hasher.update(input.as_bytes()); |
| hex::encode(hasher.finalize()) |
| } |
|
|
| |
|
|
| fn mime_from_ext(ext: &str) -> &'static str { |
| match ext { |
| ".jpg" | ".jpeg" => "image/jpeg", |
| ".png" => "image/png", |
| ".gif" => "image/gif", |
| ".webp" => "image/webp", |
| ".avif" => "image/avif", |
| ".heic" | ".heif" => "image/heic", |
| _ => "application/octet-stream", |
| } |
| } |
|
|
| fn sniff_mime_ext(bytes: &[u8]) -> (&'static str, &'static str) { |
| if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF { |
| return ("image/jpeg", ".jpeg"); |
| } |
| if bytes.len() >= 8 |
| && bytes[0] == 0x89 |
| && bytes[1] == 0x50 |
| && bytes[2] == 0x4E |
| && bytes[3] == 0x47 |
| && bytes[4] == 0x0D |
| && bytes[5] == 0x0A |
| && bytes[6] == 0x1A |
| && bytes[7] == 0x0A |
| { |
| return ("image/png", ".png"); |
| } |
| if bytes.len() >= 6 && (&bytes[0..3] == b"GIF") { |
| return ("image/gif", ".gif"); |
| } |
| if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { |
| return ("image/webp", ".webp"); |
| } |
| |
| if bytes.len() >= 16 && &bytes[4..8] == b"ftyp" { |
| let brand = &bytes[8..12]; |
| if brand == b"heic" |
| || brand == b"heif" |
| || brand == b"heix" |
| || brand == b"mif1" |
| || brand == b"msf1" |
| { |
| return ("image/heic", ".heic"); |
| } |
| } |
| ("application/octet-stream", "") |
| } |
|
|
| |
|
|
| fn try_convert_to_jpeg(bytes: &[u8], quality: u8, max_dim: u32) -> Option<Vec<u8>> { |
| let mut img = image::load_from_memory(bytes).ok()?; |
|
|
| if max_dim > 0 { |
| let (w, h) = img.dimensions(); |
| let longest = w.max(h); |
| if longest > max_dim { |
| let scale = max_dim as f32 / longest as f32; |
| let nw = ((w as f32) * scale).round().max(1.0) as u32; |
| let nh = ((h as f32) * scale).round().max(1.0) as u32; |
| img = img.resize_exact(nw, nh, image::imageops::FilterType::Lanczos3); |
| } |
| } |
|
|
| let rgb = img.to_rgb8(); |
| let mut out = Vec::new(); |
| { |
| let q = quality.clamp(1, 100); |
| let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, q); |
| encoder |
| .encode( |
| &rgb, |
| rgb.width(), |
| rgb.height(), |
| image::ExtendedColorType::Rgb8, |
| ) |
| .ok()?; |
| } |
| Some(out) |
| } |
|
|
| |
|
|
| fn find_cached_image( |
| images_dir: &Path, |
| hash: &str, |
| ) -> Option<(PathBuf, &'static str, &'static str)> { |
| let exts = [ |
| ".jpeg", ".jpg", ".png", ".gif", ".webp", ".avif", ".heic", ".heif", |
| ]; |
| for ext in exts { |
| let p = images_dir.join(format!("{hash}{ext}")); |
| if p.exists() { |
| return Some((p, mime_from_ext(ext), ext)); |
| } |
| } |
| None |
| } |
|
|
| |
|
|
| fn fetch_and_normalize_image( |
| cfg: &Config, |
| url: &str, |
| ) -> anyhow::Result<Option<(Vec<u8>, &'static str, &'static str)>> { |
| if !cfg.blocked_media_domains.is_empty() { |
| let lowered = url.to_ascii_lowercase(); |
| if cfg |
| .blocked_media_domains |
| .iter() |
| .any(|d| !d.trim().is_empty() && lowered.contains(&d.to_ascii_lowercase())) |
| { |
| return Ok(None); |
| } |
| } |
|
|
| let bytes = match crate::third_party::media_fetch::fetch_bytes( |
| url, |
| std::time::Duration::from_millis(10_000), |
| ) { |
| Some(b) => b, |
| None => return Ok(None), |
| }; |
|
|
| let (mime, ext) = sniff_mime_ext(&bytes); |
|
|
| |
| |
| |
| let should_try_jpeg = cfg.force_convert_images_to_jpeg |
| || cfg.jpeg_retry_convert && (mime == "application/octet-stream" || mime != "image/jpeg"); |
| if should_try_jpeg |
| && mime != "image/heic" |
| && let Some(jpeg) = |
| try_convert_to_jpeg(&bytes, cfg.jpeg_quality, cfg.media_max_dimension_px) |
| { |
| return Ok(Some((jpeg, "image/jpeg", ".jpeg"))); |
| } |
|
|
| |
| |
| if mime == "image/heic" { |
| if cfg.keep_heic_original { |
| return Ok(Some((bytes, "image/heic", ext))); |
| } |
| tracing::debug!(url, "HEIC 格式图片,无法解码,跳过"); |
| return Ok(None); |
| } |
|
|
| if mime == "application/octet-stream" { |
| return Ok(None); |
| } |
|
|
| Ok(Some((bytes, mime, ext))) |
| } |
|
|
| |
|
|
| pub(crate) fn ensure_cached_image( |
| cfg: &Config, |
| url: &str, |
| images_dir: &Path, |
| ) -> anyhow::Result<Option<(PathBuf, &'static str, &'static str)>> { |
| if !cfg.blocked_media_domains.is_empty() { |
| let lowered = url.to_ascii_lowercase(); |
| if cfg |
| .blocked_media_domains |
| .iter() |
| .any(|d| !d.trim().is_empty() && lowered.contains(&d.to_ascii_lowercase())) |
| { |
| return Ok(None); |
| } |
| } |
|
|
| let hash = sha1_hex(url); |
| if let Some(hit) = find_cached_image(images_dir, &hash) { |
| return Ok(Some(hit)); |
| } |
|
|
| let fetched = fetch_and_normalize_image(cfg, url)?; |
| let Some((bytes, mime, ext)) = fetched else { |
| return Ok(None); |
| }; |
|
|
| std::fs::create_dir_all(images_dir)?; |
| let out_path = images_dir.join(format!("{hash}{ext}")); |
| if !out_path.exists() { |
| let _ = write_atomic(&out_path, &bytes); |
| } |
| Ok(Some((out_path, mime, ext))) |
| } |
|
|