Skip to main content

media_decode/
lib.rs

1mod decode;
2pub mod frame_valid;
3mod meta;
4mod mime_resolve;
5mod thumbs;
6pub mod types;
7
8pub use decode::{DecodeError, decode_and_thumbnail, decode_image};
9pub use frame_valid::{is_effectively_blank, is_rgba_blank};
10pub use meta::{MediaMeta, probe_media_meta};
11
12use std::{fs::File, path::Path, str::FromStr};
13
14use ::image::{DynamicImage, ImageFormat, codecs::jpeg::JpegEncoder};
15use strum_macros::{AsRefStr, Display, EnumString};
16
17#[derive(thiserror::Error, Debug)]
18pub enum ThumbnailError {
19    #[error("IOError")]
20    IOError(#[from] std::io::Error),
21    #[error("ImageError")]
22    ImageError(#[from] ::image::ImageError),
23    #[error("PngError")]
24    PngError(#[from] oxipng::PngError),
25    #[error("AnyError")]
26    AnyError(#[from] anyhow::Error),
27    #[error("Unsupported MIME type:`{0}`")]
28    UnsupportedError(String),
29}
30
31#[derive(Debug, Copy, Clone, Display, EnumString, AsRefStr)]
32#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
33pub enum Encoding {
34    Jpeg,
35    Png,
36    Webp,
37}
38
39/// PSD 预览提取策略;调用方可在准确性和扫描开销之间显式取舍。
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub enum PsdPreviewPolicy {
42    /// 优先解析完整 composite,失败或超限时再回退到 IRB。
43    #[default]
44    PreferComposite,
45    /// 只读取约 160px 的内嵌 IRB,避免为扫描主色完整加载大文件。
46    IrbOnly,
47}
48
49/// Represents fixed sizes of a thumbnail
50#[derive(Clone, Copy, Debug)]
51pub enum ThumbnailSize {
52    Icon,
53    Small,
54    Medium,
55    Large,
56    Larger,
57    Custom((u32, u32)),
58}
59
60impl ThumbnailSize {
61    pub fn dimensions(&self) -> (u32, u32) {
62        match self {
63            ThumbnailSize::Icon => (64, 64),
64            ThumbnailSize::Small => (128, 128),
65            ThumbnailSize::Medium => (256, 256),
66            ThumbnailSize::Large => (512, 512),
67            ThumbnailSize::Larger => (1024, 1024),
68            ThumbnailSize::Custom(size) => *size,
69        }
70    }
71}
72
73/// 按 MIME/扩展名路由解码并缩放到 max_dim 边长内(svg/raw/audio/office 优先于通用 image)
74pub fn decode_for_thumbnail(path: &Path, max_dim: u32) -> Result<DynamicImage, DecodeError> {
75    decode_for_thumbnail_with_psd_policy(path, max_dim, PsdPreviewPolicy::PreferComposite)
76}
77
78/// 按 MIME/扩展名路由解码,并允许 PSD 调用方选择是否解析完整 composite。
79///
80/// 非 PSD 格式会忽略 `psd_policy`,从而保持统一入口而不改变既有路由行为。
81pub fn decode_for_thumbnail_with_psd_policy(
82    path: &Path,
83    max_dim: u32,
84    psd_policy: PsdPreviewPolicy,
85) -> Result<DynamicImage, DecodeError> {
86    let mime = mime_resolve::resolve_mime(path);
87    let ext = path
88        .extension()
89        .and_then(|e| e.to_str())
90        .unwrap_or("")
91        .to_ascii_lowercase();
92
93    #[cfg(feature = "svg")]
94    if mime_resolve::is_svg_mime(&mime) || ext == "svg" {
95        use crate::thumbs::svg;
96        return svg::create_thumbnail(path, max_dim).map_err(|_| DecodeError::Unsupported);
97    }
98
99    #[cfg(feature = "raw")]
100    if mime_resolve::is_raw_ext(&ext) {
101        use crate::thumbs::raw;
102        return raw::create_thumbnail(path, max_dim).map_err(|_| DecodeError::Unsupported);
103    }
104
105    #[cfg(feature = "audio")]
106    if mime_resolve::is_audio_mime(&mime) || mime_resolve::is_audio_ext(&ext) {
107        use crate::thumbs::audio;
108        return audio::extract_cover(path, max_dim).ok_or(DecodeError::Unsupported);
109    }
110
111    #[cfg(feature = "office")]
112    if mime_resolve::is_office_mime(&mime) || mime_resolve::is_office_ext(&ext) {
113        use crate::thumbs::office;
114        return office::create_thumbnail(path, max_dim).ok_or(DecodeError::Unsupported);
115    }
116
117    #[cfg(feature = "source")]
118    if crate::thumbs::source::is_source_ext(&ext) {
119        use crate::thumbs::source;
120        return source::create_thumbnail_with_psd_policy(path, max_dim, psd_policy)
121            .ok_or(DecodeError::Unsupported);
122    }
123
124    decode_and_thumbnail(path, max_dim)
125}
126
127/// 读取 Office ZIP 内 EMF 缩略图原始字节(供应用层 GDI 栅格化)
128#[cfg(feature = "office")]
129pub fn extract_office_emf_bytes(path: &Path) -> Option<Vec<u8>> {
130    crate::thumbs::office::extract_emf_bytes(path)
131}
132
133/// 逐页渲染 PDF 位图(OCR / 扫描件评估用);绑定失败或损坏返回 None。
134#[cfg(feature = "pdf")]
135pub fn render_pdf_pages(path: &Path, max_pages: usize) -> Option<Vec<DynamicImage>> {
136    crate::thumbs::pdf::render_pdf_pages(path, max_pages)
137}
138
139/// 逐页渲染 PDF,可选长边上限(像素)。
140#[cfg(feature = "pdf")]
141pub fn render_pdf_pages_with_limit(
142    path: &Path,
143    max_pages: usize,
144    max_long_side: Option<u32>,
145) -> Option<Vec<DynamicImage>> {
146    crate::thumbs::pdf::render_pdf_pages_with_limit(path, max_pages, max_long_side)
147}
148
149/// OCR 专用 PDF 渲染(默认 2× 缩放)。
150#[cfg(feature = "pdf")]
151pub fn render_pdf_pages_for_ocr(
152    path: &Path,
153    max_pages: usize,
154    scale: f32,
155) -> Option<Vec<DynamicImage>> {
156    crate::thumbs::pdf::render_pdf_pages_for_ocr(path, max_pages, scale)
157}
158
159/// 逐页提取 PDF 文本(语义搜索/内容索引用):返回按页序的文本,无文本层页为空串。
160/// 内部复用与缩略图渲染同一把 pdfium 全局锁,宿主无需关心 FFI 线程安全;
161/// 绑定失败或文档损坏返回 None。
162#[cfg(feature = "pdf")]
163pub fn extract_pdf_pages_text(path: &Path, max_pages: usize) -> Option<Vec<String>> {
164    crate::thumbs::pdf::extract_pages_text(path, max_pages)
165}
166
167/// 均匀抽取视频帧:需 `video` feature 与系统 FFmpeg。
168#[cfg(feature = "video")]
169pub fn decode_video_sample_frames(path: &Path, max_frames: usize) -> Vec<DynamicImage> {
170    crate::decode::ffmpeg_decode::decode_video_sample_frames(path, max_frames)
171}
172
173pub struct Thumbnailer {
174    /// The maximum output width.
175    pub width: u32,
176    /// The maximum output height.
177    pub height: u32,
178    /// Encode the image with the given quality.
179    /// Only support Jpeg and Webp.
180    /// The image quality must be between 1 and 100 inclusive for minimal and maximal quality respectively.
181    pub quality: u8,
182}
183
184impl Default for Thumbnailer {
185    fn default() -> Self {
186        Self::new(ThumbnailSize::Medium, 90)
187    }
188}
189
190impl Thumbnailer {
191    pub fn new(size: ThumbnailSize, quality: u8) -> Self {
192        let (width, height) = size.dimensions();
193        Self {
194            width,
195            height,
196            quality,
197        }
198    }
199
200    /// create thumbnail image.
201    /// path: source file path.
202    /// output: thumbnail image path.
203    pub fn create_thumbnail<P, T>(
204        &'_ self,
205        path: P,
206        output: T,
207    ) -> anyhow::Result<(), ThumbnailError>
208    where
209        P: AsRef<Path>,
210        T: AsRef<Path>,
211    {
212        let path = path.as_ref();
213        let mime = mime_resolve::resolve_mime(path);
214        let ext = path
215            .extension()
216            .and_then(|e| e.to_str())
217            .unwrap_or("")
218            .to_ascii_lowercase();
219
220        let encoding = output
221            .as_ref()
222            .extension()
223            .and_then(|ext| ext.to_ascii_uppercase().to_str().map(str::to_string))
224            .and_then(|ext| Encoding::from_str(&ext).ok())
225            .unwrap_or_else(|| {
226                log::debug!("Defaulting encoding to Jpeg");
227                Encoding::Jpeg
228            });
229
230        let max_dim = self.width.max(self.height);
231
232        #[cfg(feature = "svg")]
233        if mime_resolve::is_svg_mime(&mime) || ext == "svg" {
234            use crate::thumbs::svg;
235            let img = svg::create_thumbnail(path, max_dim)?;
236            self.encod_and_save(img, encoding, output)?;
237            return Ok(());
238        }
239
240        #[cfg(feature = "raw")]
241        if mime_resolve::is_raw_ext(&ext) {
242            use crate::thumbs::raw;
243            let img = raw::create_thumbnail(path, max_dim)?;
244            self.encod_and_save(img, encoding, output)?;
245            return Ok(());
246        }
247
248        #[cfg(feature = "audio")]
249        if mime_resolve::is_audio_mime(&mime) || mime_resolve::is_audio_ext(&ext) {
250            use crate::thumbs::audio;
251            let img = audio::extract_cover(path, max_dim)
252                .ok_or_else(|| ThumbnailError::UnsupportedError(mime.clone()))?;
253            self.encod_and_save(img, encoding, output)?;
254            return Ok(());
255        }
256
257        #[cfg(feature = "office")]
258        if mime_resolve::is_office_mime(&mime) || mime_resolve::is_office_ext(&ext) {
259            use crate::thumbs::office;
260            let img = office::create_thumbnail(path, max_dim)
261                .ok_or_else(|| ThumbnailError::UnsupportedError(mime.clone()))?;
262            self.encod_and_save(img, encoding, output)?;
263            return Ok(());
264        }
265
266        #[cfg(feature = "source")]
267        if crate::thumbs::source::is_source_ext(&ext) {
268            use crate::thumbs::source;
269            let img = source::create_thumbnail(path, max_dim)
270                .ok_or_else(|| ThumbnailError::UnsupportedError(mime.clone()))?;
271            self.encod_and_save(img, encoding, output)?;
272            return Ok(());
273        }
274
275        #[cfg(feature = "image")]
276        if mime_resolve::is_image_mime(&mime) {
277            use crate::thumbs::image;
278
279            let img = image::create_thumbnail(path, self.width, self.height)?;
280            self.encod_and_save(img, encoding, output)?;
281            return Ok(());
282        }
283
284        #[cfg(feature = "pdf")]
285        if mime_resolve::is_pdf_mime(&mime) {
286            use crate::thumbs::pdf;
287
288            let img = pdf::create_thumbnail(path, self.width, self.height)?;
289            self.encod_and_save(img, encoding, output)?;
290            return Ok(());
291        }
292
293        #[cfg(feature = "video")]
294        if mime_resolve::is_video_mime(&mime) {
295            use crate::thumbs::video;
296
297            let img = video::create_thumbnail(path, self.width, self.height)?;
298            self.encod_and_save(img, encoding, output)?;
299            return Ok(());
300        }
301
302        Err(ThumbnailError::UnsupportedError(mime))
303    }
304
305    fn encod_and_save<P>(
306        &'_ self,
307        img: DynamicImage,
308        encoding: Encoding,
309        output: P,
310    ) -> anyhow::Result<(), ThumbnailError>
311    where
312        P: AsRef<Path>,
313    {
314        match encoding {
315            Encoding::Jpeg => {
316                let output = File::create(output)?;
317                let encoder = JpegEncoder::new_with_quality(output, self.quality);
318                img.write_with_encoder(encoder)?;
319            }
320            Encoding::Png => {
321                img.save_with_format(&output, ImageFormat::Png)?;
322
323                oxipng::optimize(
324                    &oxipng::InFile::Path(output.as_ref().to_path_buf()),
325                    &oxipng::OutFile::from_path(output.as_ref().to_path_buf()),
326                    &oxipng::Options::max_compression(),
327                )?;
328            }
329            Encoding::Webp => {
330                let rgba = img.to_rgba8();
331                let encoder = webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height());
332                let memory = encoder.encode(self.quality.into());
333                std::fs::write(output, &*memory)?;
334            }
335        };
336
337        Ok(())
338    }
339}