pinakes/crates/pinakes-core/src/thumbnail.rs
NotAShelf 3ccddce7fd
treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
2026-03-06 18:29:33 +03:00

565 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,
};
/// 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).
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(),
)
}
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!("{}.jpg", media_id));
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<()> {
// Use pdftoppm to render first page, then resize with image crate
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
let rendered = temp_prefix.with_extension("jpg");
if rendered.exists() {
// Resize to thumbnail size
let img = image::open(&rendered).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}"))
})?;
let _ = std::fs::remove_file(&rendered);
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<()> {
// Try dcraw to extract embedded JPEG preview, then resize
let temp_ppm = dest.with_extension("ppm");
let status = Command::new("dcraw")
.args(["-e", "-c"])
.arg(source)
.stdout(std::fs::File::create(&temp_ppm).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() {
let _ = std::fs::remove_file(&temp_ppm);
return Err(PinakesError::MetadataExtraction(format!(
"dcraw exited with status {}",
status
)));
}
// The extracted preview is typically a JPEG — try loading it
if temp_ppm.exists() {
let result = image::open(&temp_ppm);
let _ = std::fs::remove_file(&temp_ppm);
let img = result.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}"))
})?;
Ok(())
} else {
Err(PinakesError::MetadataExtraction(
"dcraw did not produce output".to_string(),
))
}
}
fn generate_heic_thumbnail(
source: &Path,
dest: &Path,
config: &ThumbnailConfig,
) -> Result<()> {
// Use heif-convert to convert to JPEG, then resize
let temp_jpg = dest.with_extension("tmp.jpg");
let status = Command::new("heif-convert")
.arg(source)
.arg(&temp_jpg)
.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() {
let _ = std::fs::remove_file(&temp_jpg);
return Err(PinakesError::MetadataExtraction(format!(
"heif-convert exited with status {}",
status
)));
}
if temp_jpg.exists() {
let result = image::open(&temp_jpg);
let _ = std::fs::remove_file(&temp_jpg);
let img = result.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}"))
})?;
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 {
pub fn dimensions(&self) -> Option<(u32, u32)> {
match self {
CoverSize::Tiny => Some((64, 64)),
CoverSize::Grid => Some((320, 320)),
CoverSize::Preview => Some((1024, 1024)),
CoverSize::Original => None, // No resizing
}
}
pub fn filename(&self) -> &'static str {
match self {
CoverSize::Tiny => "tiny.jpg",
CoverSize::Grid => "grid.jpg",
CoverSize::Preview => "preview.jpg",
CoverSize::Original => "original.jpg",
}
}
}
/// Generate multi-resolution covers for a book
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());
match size.dimensions() {
Some((width, height)) => {
// 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}"))
})?;
},
None => {
// 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
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)
pub fn extract_pdf_cover(pdf_path: &Path) -> Result<Option<Vec<u8>>> {
// Use pdftoppm to extract the first page at high resolution
let pdftoppm = "pdftoppm";
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
let output_path = format!("{}-1.jpg", temp_prefix.display());
let output_pathbuf = PathBuf::from(&output_path);
if output_pathbuf.exists() {
let data = std::fs::read(&output_pathbuf)?;
let _ = std::fs::remove_file(&output_pathbuf);
Ok(Some(data))
} else {
Ok(None)
}
}
/// Returns the default covers directory under the data dir
pub fn default_covers_dir() -> PathBuf {
crate::config::Config::default_data_dir().join("covers")
}
/// Returns the default thumbnail directory under the data dir.
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
pub fn pixels(&self) -> u32 {
match self {
ThumbnailSize::Tiny => 64,
ThumbnailSize::Grid => 320,
ThumbnailSize::Preview => 1024,
}
}
/// Get the subdirectory name for this size
pub fn subdir_name(&self) -> &'static str {
match self {
ThumbnailSize::Tiny => "tiny",
ThumbnailSize::Grid => "grid",
ThumbnailSize::Preview => "preview",
}
}
}
/// Generate all thumbnail sizes for a media file
/// Returns paths to the generated thumbnails (tiny, grid, preview)
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.clone(),
&size_dir,
&config,
)?;
results.push(result);
}
Ok((results[0].clone(), results[1].clone(), results[2].clone()))
}