File size: 7,096 Bytes
bc32e7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
//! 图片下载、缓存、格式转换。
//!
//! 负责从网络获取图片、本地缓存检查、JPEG 转码等。

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())
}

// ── MIME / 扩展名 ───────────────────────────────────────────────

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");
    }
    // HEIC/HEIF detection: ISO BMFF 'ftyp' brand.
    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", "")
}

// ── JPEG 转码 ───────────────────────────────────────────────────

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);

    // 转码逻辑:
    // - force_convert_images_to_jpeg=true:无条件尽量转
    // - jpeg_retry_convert=true:当识别失败/或非 jpeg 时尝试转(可提升兼容性)
    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")));
    }

    // HEIC/HEIF:image crate 无原生支持,无法解码。
    // WebUI 封面已通过 HTML <img> 标签获取浏览器兼容格式,此处仅保留原始数据或跳过。
    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)))
}