pinakes/crates/pinakes-core/src/thumbnail.rs
NotAShelf b6e579408f
chore: fix clippy lints; format
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib3d98a81c7e41054d27e617394bef63c6a6a6964
2026-03-11 21:31:43 +03:00

603 lines
16 KiB
Rust

use std::{
path::{Path, PathBuf},
process::Command,
};
use tracing::{info, warn};
use crate::{
config::ThumbnailConfig,
error::{PinakesError, Result},
media_type::{BuiltinMediaType, MediaCategory, MediaType},
model::MediaId,
};
/// RAII guard that removes a file on drop.
struct TempFileGuard(PathBuf);
impl TempFileGuard {
const fn new(path: PathBuf) -> Self {
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if self.0.exists()
&& let Err(e) = std::fs::remove_file(&self.0) {
warn!("failed to clean up temp file {}: {e}", self.0.display());
}
}
}
/// Generate a thumbnail for a media file and return the path to the thumbnail.
///
/// Supports images (via `image` crate), videos (via ffmpeg), PDFs (via
/// pdftoppm), and EPUBs (via cover image extraction).
///
/// # Errors
///
/// Returns [`PinakesError`] if thumbnail generation fails.
pub fn generate_thumbnail(
media_id: MediaId,
source_path: &Path,
media_type: &MediaType,
thumbnail_dir: &Path,
) -> Result<Option<PathBuf>> {
generate_thumbnail_with_config(
media_id,
source_path,
media_type,
thumbnail_dir,
&ThumbnailConfig::default(),
)
}
/// Generate a thumbnail with custom configuration.
///
/// # Errors
///
/// Returns [`PinakesError`] if thumbnail generation fails.
pub fn generate_thumbnail_with_config(
media_id: MediaId,
source_path: &Path,
media_type: &MediaType,
thumbnail_dir: &Path,
config: &ThumbnailConfig,
) -> Result<Option<PathBuf>> {
std::fs::create_dir_all(thumbnail_dir)?;
let thumb_path = thumbnail_dir.join(format!("{media_id}.jpg"));
let result = match media_type.category() {
MediaCategory::Image => {
if media_type.is_raw() {
generate_raw_thumbnail(source_path, &thumb_path, config)
} else if *media_type == MediaType::Builtin(BuiltinMediaType::Heic) {
generate_heic_thumbnail(source_path, &thumb_path, config)
} else {
generate_image_thumbnail(source_path, &thumb_path, config)
}
},
MediaCategory::Video => {
generate_video_thumbnail(source_path, &thumb_path, config)
},
MediaCategory::Document => {
match media_type {
MediaType::Builtin(BuiltinMediaType::Pdf) => {
generate_pdf_thumbnail(source_path, &thumb_path, config)
},
MediaType::Builtin(BuiltinMediaType::Epub) => {
generate_epub_thumbnail(source_path, &thumb_path, config)
},
_ => return Ok(None),
}
},
_ => return Ok(None),
};
match result {
Ok(()) => {
info!(media_id = %media_id, category = ?media_type.category(), "generated thumbnail");
Ok(Some(thumb_path))
},
Err(e) => {
warn!(media_id = %media_id, error = %e, "failed to generate thumbnail");
Ok(None)
},
}
}
fn generate_image_thumbnail(
source: &Path,
dest: &Path,
config: &ThumbnailConfig,
) -> Result<()> {
let img = image::open(source).map_err(|e| {
PinakesError::MetadataExtraction(format!("image open: {e}"))
})?;
let thumb = img.thumbnail(config.size, config.size);
let mut output = std::fs::File::create(dest)?;
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
config.quality,
);
thumb.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("thumbnail encode: {e}"))
})?;
Ok(())
}
fn generate_video_thumbnail(
source: &Path,
dest: &Path,
config: &ThumbnailConfig,
) -> Result<()> {
let ffmpeg = config.ffmpeg_path.as_deref().unwrap_or("ffmpeg");
let status = Command::new(ffmpeg)
.args(["-ss", &config.video_seek_secs.to_string(), "-i"])
.arg(source)
.args([
"-vframes",
"1",
"-vf",
&format!("scale={}:{}", config.size, config.size),
"-y",
])
.arg(dest)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| {
PinakesError::MetadataExtraction(format!(
"ffmpeg not found or failed to execute: {e}"
))
})?;
if !status.success() {
return Err(PinakesError::MetadataExtraction(format!(
"ffmpeg exited with status {status}"
)));
}
Ok(())
}
fn generate_pdf_thumbnail(
source: &Path,
dest: &Path,
config: &ThumbnailConfig,
) -> Result<()> {
let temp_prefix = dest.with_extension("tmp");
let status = Command::new("pdftoppm")
.args(["-jpeg", "-f", "1", "-l", "1", "-singlefile"])
.arg(source)
.arg(&temp_prefix)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| {
PinakesError::MetadataExtraction(format!(
"pdftoppm not found or failed to execute: {e}"
))
})?;
if !status.success() {
return Err(PinakesError::MetadataExtraction(format!(
"pdftoppm exited with status {status}"
)));
}
// pdftoppm outputs <prefix>.jpg -- guard ensures cleanup on all paths
let rendered = TempFileGuard::new(temp_prefix.with_extension("jpg"));
if rendered.path().exists() {
let img = image::open(rendered.path()).map_err(|e| {
PinakesError::MetadataExtraction(format!("pdf thumbnail open: {e}"))
})?;
let thumb = img.thumbnail(config.size, config.size);
let mut output = std::fs::File::create(dest)?;
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
config.quality,
);
thumb.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("pdf thumbnail encode: {e}"))
})?;
// Guard drops here and cleans up the temp file
Ok(())
} else {
Err(PinakesError::MetadataExtraction(
"pdftoppm did not produce output".to_string(),
))
}
}
fn generate_epub_thumbnail(
source: &Path,
dest: &Path,
config: &ThumbnailConfig,
) -> Result<()> {
// Try to extract cover image from EPUB
let mut doc = epub::doc::EpubDoc::new(source)
.map_err(|e| PinakesError::MetadataExtraction(format!("epub open: {e}")))?;
let cover_data = doc.get_cover().map(|(data, _mime)| data).or_else(|| {
// Fallback: try to find a cover image in the resources
doc
.get_resource("cover-image")
.map(|(data, _)| data)
.or_else(|| doc.get_resource("cover").map(|(data, _)| data))
});
if let Some(data) = cover_data {
let img = image::load_from_memory(&data).map_err(|e| {
PinakesError::MetadataExtraction(format!("epub cover decode: {e}"))
})?;
let thumb = img.thumbnail(config.size, config.size);
let mut output = std::fs::File::create(dest)?;
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
config.quality,
);
thumb.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("epub thumbnail encode: {e}"))
})?;
Ok(())
} else {
Err(PinakesError::MetadataExtraction(
"no cover image found in epub".to_string(),
))
}
}
fn generate_raw_thumbnail(
source: &Path,
dest: &Path,
config: &ThumbnailConfig,
) -> Result<()> {
let temp_ppm = TempFileGuard::new(dest.with_extension("ppm"));
let status = Command::new("dcraw")
.args(["-e", "-c"])
.arg(source)
.stdout(std::fs::File::create(temp_ppm.path()).map_err(|e| {
PinakesError::MetadataExtraction(format!(
"failed to create temp file: {e}"
))
})?)
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| {
PinakesError::MetadataExtraction(format!(
"dcraw not found or failed: {e}"
))
})?;
if !status.success() {
// Guard drops and cleans up temp_ppm
return Err(PinakesError::MetadataExtraction(format!(
"dcraw exited with status {status}"
)));
}
if temp_ppm.path().exists() {
let img = image::open(temp_ppm.path()).map_err(|e| {
PinakesError::MetadataExtraction(format!("raw preview decode: {e}"))
})?;
let thumb = img.thumbnail(config.size, config.size);
let mut output = std::fs::File::create(dest)?;
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
config.quality,
);
thumb.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("raw thumbnail encode: {e}"))
})?;
// Guard drops and cleans up temp_ppm
Ok(())
} else {
Err(PinakesError::MetadataExtraction(
"dcraw did not produce output".to_string(),
))
}
}
fn generate_heic_thumbnail(
source: &Path,
dest: &Path,
config: &ThumbnailConfig,
) -> Result<()> {
let temp_jpg = TempFileGuard::new(dest.with_extension("tmp.jpg"));
let status = Command::new("heif-convert")
.arg(source)
.arg(temp_jpg.path())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| {
PinakesError::MetadataExtraction(format!(
"heif-convert not found or failed: {e}"
))
})?;
if !status.success() {
// Guard drops and cleans up temp_jpg
return Err(PinakesError::MetadataExtraction(format!(
"heif-convert exited with status {status}"
)));
}
if temp_jpg.path().exists() {
let img = image::open(temp_jpg.path()).map_err(|e| {
PinakesError::MetadataExtraction(format!("heic decode: {e}"))
})?;
let thumb = img.thumbnail(config.size, config.size);
let mut output = std::fs::File::create(dest)?;
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
config.quality,
);
thumb.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("heic thumbnail encode: {e}"))
})?;
// Guard drops and cleans up temp_jpg
Ok(())
} else {
Err(PinakesError::MetadataExtraction(
"heif-convert did not produce output".to_string(),
))
}
}
/// Cover size variants for book covers
#[derive(Debug, Clone, Copy)]
pub enum CoverSize {
Tiny, // 64x64 - for map markers, timeline
Grid, // 320x320 - for library grid view
Preview, // 1024x1024 - for quick fullscreen preview
Original, // Full size - original cover
}
impl CoverSize {
#[must_use]
pub const fn dimensions(&self) -> Option<(u32, u32)> {
match self {
Self::Tiny => Some((64, 64)),
Self::Grid => Some((320, 320)),
Self::Preview => Some((1024, 1024)),
Self::Original => None, // No resizing
}
}
#[must_use]
pub const fn filename(&self) -> &'static str {
match self {
Self::Tiny => "tiny.jpg",
Self::Grid => "grid.jpg",
Self::Preview => "preview.jpg",
Self::Original => "original.jpg",
}
}
}
/// Generate multi-resolution covers for a book.
///
/// # Errors
///
/// Returns [`PinakesError`] if the cover image cannot be decoded or encoded.
pub fn generate_book_covers(
media_id: MediaId,
source_image: &[u8],
covers_dir: &Path,
) -> Result<Vec<(CoverSize, PathBuf)>> {
// Create cover directory for this media item
let media_cover_dir = covers_dir.join(media_id.to_string());
std::fs::create_dir_all(&media_cover_dir)?;
let img = image::load_from_memory(source_image).map_err(|e| {
PinakesError::MetadataExtraction(format!("cover image load: {e}"))
})?;
let mut results = Vec::new();
// Generate each size variant
for size in [
CoverSize::Tiny,
CoverSize::Grid,
CoverSize::Preview,
CoverSize::Original,
] {
let cover_path = media_cover_dir.join(size.filename());
if let Some((width, height)) = size.dimensions() {
// Generate thumbnail
let thumb = img.thumbnail(width, height);
let mut output = std::fs::File::create(&cover_path)?;
let encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90);
thumb.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("cover encode: {e}"))
})?;
} else {
// Save original
let mut output = std::fs::File::create(&cover_path)?;
let encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
img.write_with_encoder(encoder).map_err(|e| {
PinakesError::MetadataExtraction(format!("cover encode: {e}"))
})?;
}
results.push((size, cover_path));
}
Ok(results)
}
/// Extract full-size cover from an EPUB file.
///
/// # Errors
///
/// Returns [`PinakesError`] if the EPUB cannot be opened or read.
pub fn extract_epub_cover(epub_path: &Path) -> Result<Option<Vec<u8>>> {
let mut doc = epub::doc::EpubDoc::new(epub_path)
.map_err(|e| PinakesError::MetadataExtraction(format!("EPUB open: {e}")))?;
// Try to get the cover image
if let Some(cover_id) = doc.get_cover_id()
&& let Some((cover_data, _mime)) = doc.get_resource(&cover_id)
{
return Ok(Some(cover_data));
}
// Fallback: look for common cover image filenames
let cover_names = [
"cover.jpg",
"cover.jpeg",
"cover.png",
"Cover.jpg",
"Cover.jpeg",
"Cover.png",
];
for name in &cover_names {
if let Some(data) = doc.get_resource_by_path(name) {
return Ok(Some(data));
}
}
Ok(None)
}
/// Extract full-size cover from a PDF file (first page).
///
/// # Errors
///
/// Returns [`PinakesError`] if pdftoppm cannot be executed or fails.
pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
let temp_dir = std::env::temp_dir();
let temp_prefix =
temp_dir.join(format!("pdf_cover_{}", uuid::Uuid::new_v4()));
let status = Command::new("pdftoppm")
.args(["-jpeg", "-f", "1", "-l", "1", "-scale-to", "1200"])
.arg(pdf_path)
.arg(&temp_prefix)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| PinakesError::MetadataExtraction(format!("pdftoppm: {e}")))?;
if !status.success() {
return Err(PinakesError::MetadataExtraction(format!(
"pdftoppm exited with status {status}"
)));
}
// pdftoppm outputs files like prefix-1.jpg -- guard ensures cleanup
let output = TempFileGuard::new(PathBuf::from(format!(
"{}-1.jpg",
temp_prefix.display()
)));
if output.path().exists() {
let data = std::fs::read(output.path())?;
// Guard drops and cleans up the temp file
Ok(Some(data))
} else {
Ok(None)
}
}
/// Returns the default covers directory under the data dir
#[must_use]
pub fn default_covers_dir() -> PathBuf {
crate::config::Config::default_data_dir().join("covers")
}
/// Returns the default thumbnail directory under the data dir.
#[must_use]
pub fn default_thumbnail_dir() -> PathBuf {
crate::config::Config::default_data_dir().join("thumbnails")
}
/// Thumbnail size variant for multi-resolution support
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThumbnailSize {
/// Tiny thumbnail for map markers and icons (64x64)
Tiny,
/// Grid thumbnail for library grid view (320x320)
Grid,
/// Preview thumbnail for quick fullscreen preview (1024x1024)
Preview,
}
impl ThumbnailSize {
/// Get the pixel size for this thumbnail variant
#[must_use]
pub const fn pixels(&self) -> u32 {
match self {
Self::Tiny => 64,
Self::Grid => 320,
Self::Preview => 1024,
}
}
/// Get the subdirectory name for this size
#[must_use]
pub const fn subdir_name(&self) -> &'static str {
match self {
Self::Tiny => "tiny",
Self::Grid => "grid",
Self::Preview => "preview",
}
}
}
/// Generate all thumbnail sizes for a media file.
///
/// Returns paths to the generated thumbnails (tiny, grid, preview).
///
/// # Errors
///
/// Returns [`PinakesError`] if thumbnail generation fails.
pub fn generate_all_thumbnail_sizes(
media_id: MediaId,
source_path: &Path,
media_type: &MediaType,
thumbnail_base_dir: &Path,
) -> Result<(Option<PathBuf>, Option<PathBuf>, Option<PathBuf>)> {
let sizes = [
ThumbnailSize::Tiny,
ThumbnailSize::Grid,
ThumbnailSize::Preview,
];
let mut results = Vec::new();
for size in &sizes {
let size_dir = thumbnail_base_dir.join(size.subdir_name());
std::fs::create_dir_all(&size_dir)?;
let config = ThumbnailConfig {
size: size.pixels(),
..ThumbnailConfig::default()
};
let result = generate_thumbnail_with_config(
media_id,
source_path,
media_type,
&size_dir,
&config,
)?;
results.push(result);
}
Ok((results[0].clone(), results[1].clone(), results[2].clone()))
}