pinakes/crates/pinakes-core/src/thumbnail.rs
NotAShelf 6a73d11c4b
initial commit
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4a6b498153eccd5407510dd541b7f4816a6a6964
2026-01-31 15:20:30 +03:00

278 lines
9.9 KiB
Rust

use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{info, warn};
use crate::config::ThumbnailConfig;
use crate::error::{PinakesError, Result};
use crate::media_type::{MediaCategory, MediaType};
use crate::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::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::Pdf => generate_pdf_thumbnail(source_path, &thumb_path, config),
MediaType::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(),
))
}
}
/// Returns the default thumbnail directory under the data dir.
pub fn default_thumbnail_dir() -> PathBuf {
crate::config::Config::default_data_dir().join("thumbnails")
}