Support HEIC media imports
This commit is contained in:
@@ -388,6 +388,7 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
|
||||
"scripts_created": report.scripts_created,
|
||||
"scripts_updated": report.scripts_updated,
|
||||
"thumbnails_generated": thumbnails.thumbnails_generated,
|
||||
"thumbnail_media_failed": thumbnails.media_failed,
|
||||
}),
|
||||
);
|
||||
result.progress = progress
|
||||
@@ -446,7 +447,12 @@ fn repair(db: &Database, part: RepairPart) -> Result<CommandOutput> {
|
||||
})?;
|
||||
Ok(output(
|
||||
"Missing thumbnails regenerated",
|
||||
json!({"media_repaired": report.media_repaired, "thumbnails_generated": report.thumbnails_generated}),
|
||||
json!({
|
||||
"media_processed": report.media_processed,
|
||||
"media_repaired": report.media_repaired,
|
||||
"media_failed": report.media_failed,
|
||||
"thumbnails_generated": report.thumbnails_generated,
|
||||
}),
|
||||
))
|
||||
}
|
||||
RepairPart::Search => {
|
||||
|
||||
@@ -19,6 +19,7 @@ unicode-normalization = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
walkdir = { workspace = true }
|
||||
image = { workspace = true }
|
||||
hpvcd = { workspace = true }
|
||||
webp = { workspace = true }
|
||||
rust-stemmers = { workspace = true }
|
||||
sys-locale = { workspace = true }
|
||||
|
||||
@@ -41,7 +41,9 @@ pub struct MediaLinkRebuildReport {
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct ThumbnailRepairReport {
|
||||
pub media_processed: usize,
|
||||
pub media_repaired: usize,
|
||||
pub media_failed: usize,
|
||||
pub thumbnails_generated: usize,
|
||||
}
|
||||
|
||||
@@ -111,9 +113,10 @@ pub fn regenerate_missing_thumbnails(
|
||||
) -> EngineResult<ThumbnailRepairReport> {
|
||||
let mut report = ThumbnailRepairReport::default();
|
||||
for item in qm::list_media_by_project(conn, project_id)? {
|
||||
if !item.mime_type.starts_with("image/") {
|
||||
if !item.mime_type.starts_with("image/") || item.mime_type.contains("svg") {
|
||||
continue;
|
||||
}
|
||||
report.media_processed += 1;
|
||||
let prefix = &item.id[..2.min(item.id.len())];
|
||||
let missing = THUMBNAIL_SIZES
|
||||
.iter()
|
||||
@@ -132,14 +135,17 @@ pub fn regenerate_missing_thumbnails(
|
||||
if missing == 0 {
|
||||
continue;
|
||||
}
|
||||
generate_all_thumbnails(
|
||||
match generate_all_thumbnails(
|
||||
&data_dir.join(&item.file_path),
|
||||
&data_dir.join("thumbnails"),
|
||||
&item.id,
|
||||
)
|
||||
.map_err(EngineError::Parse)?;
|
||||
report.media_repaired += 1;
|
||||
report.thumbnails_generated += missing;
|
||||
) {
|
||||
Ok(_) => {
|
||||
report.media_repaired += 1;
|
||||
report.thumbnails_generated += missing;
|
||||
}
|
||||
Err(_) => report.media_failed += 1,
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
@@ -968,6 +974,12 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
fn heic_fixture() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("testdata")
|
||||
.join("close.heic")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_media_creates_artifacts() {
|
||||
let (db, dir) = setup();
|
||||
@@ -1032,6 +1044,103 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_heic_and_heif_create_dimensions_and_thumbnails() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
for (original_name, mime_type) in
|
||||
[("photo.heic", "image/heic"), ("photo.heif", "image/heif")]
|
||||
{
|
||||
let media = import_media(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&heic_fixture(),
|
||||
original_name,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(media.mime_type, mime_type);
|
||||
assert_eq!((media.width, media.height), (Some(27), Some(27)));
|
||||
for size in THUMBNAIL_SIZES {
|
||||
let extension = match size.format {
|
||||
ThumbnailFormat::Webp => "webp",
|
||||
ThumbnailFormat::Jpeg => "jpg",
|
||||
};
|
||||
assert!(
|
||||
dir.path()
|
||||
.join("thumbnails")
|
||||
.join(&media.id[..2])
|
||||
.join(format!("{}-{}.{}", media.id, size.name, extension))
|
||||
.is_file(),
|
||||
"missing {} thumbnail",
|
||||
size.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regenerate_missing_thumbnails_reports_undecodable_media_and_continues() {
|
||||
let (db, dir) = setup();
|
||||
let good = import_media(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&create_test_png(dir.path()),
|
||||
"good.png",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
let bad_source = dir.path().join("bad.png");
|
||||
fs::write(&bad_source, "not an image").unwrap();
|
||||
let bad = import_media(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&bad_source,
|
||||
"bad.png",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
let good_small = dir
|
||||
.path()
|
||||
.join("thumbnails")
|
||||
.join(&good.id[..2])
|
||||
.join(format!("{}-small.webp", good.id));
|
||||
fs::remove_file(good_small).unwrap();
|
||||
|
||||
let report = regenerate_missing_thumbnails(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert_eq!(report.media_processed, 2);
|
||||
assert_eq!(report.media_repaired, 1);
|
||||
assert_eq!(report.media_failed, 1);
|
||||
assert_eq!(report.thumbnails_generated, 1);
|
||||
assert!(
|
||||
!dir.path()
|
||||
.join("thumbnails")
|
||||
.join(&bad.id[..2])
|
||||
.join(format!("{}-small.webp", bad.id))
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_media_rewrites_sidecar() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
@@ -881,21 +881,16 @@ impl CoreHost {
|
||||
Ok(json!({"generated": true, "media_id": item.id}))
|
||||
}
|
||||
"regenerate_missing_thumbnails" => {
|
||||
let mut generated = 0;
|
||||
for item in media::list_media_by_project(db.conn(), &self.project_id)? {
|
||||
let path = self
|
||||
.data_dir
|
||||
.join(crate::util::thumbnail_path(&item.id, "small", "webp"));
|
||||
if !path.is_file() {
|
||||
crate::util::thumbnail::generate_all_thumbnails(
|
||||
&self.data_dir.join(&item.file_path),
|
||||
&self.data_dir.join("thumbnails"),
|
||||
&item.id,
|
||||
)?;
|
||||
generated += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"generated": generated}))
|
||||
let report = engine::media::regenerate_missing_thumbnails(
|
||||
db.conn(),
|
||||
&self.data_dir,
|
||||
&self.project_id,
|
||||
)?;
|
||||
Ok(json!({
|
||||
"processed": report.media_processed,
|
||||
"generated": report.thumbnails_generated,
|
||||
"failed": report.media_failed,
|
||||
}))
|
||||
}
|
||||
"reindex_text" => {
|
||||
engine::search::reindex_project(db.conn(), &self.project_id, None)?;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, GenericImageView, ImageReader};
|
||||
use image::{DynamicImage, GenericImageView, ImageReader, RgbImage, RgbaImage};
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
@@ -70,7 +70,15 @@ pub fn generate_thumbnail(
|
||||
quality: u8,
|
||||
) -> Result<(), String> {
|
||||
let img = load_and_orient(source)?;
|
||||
generate_thumbnail_from_image(&img, dest, size, quality)
|
||||
}
|
||||
|
||||
fn generate_thumbnail_from_image(
|
||||
img: &DynamicImage,
|
||||
dest: &Path,
|
||||
size: &ThumbnailSize,
|
||||
quality: u8,
|
||||
) -> Result<(), String> {
|
||||
let resized = match size.fit {
|
||||
ThumbnailFit::Inside => {
|
||||
let (orig_w, orig_h) = img.dimensions();
|
||||
@@ -129,6 +137,7 @@ pub fn generate_all_thumbnails(
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut paths = Vec::new();
|
||||
let prefix = &media_id[..2.min(media_id.len())];
|
||||
let image = load_and_orient(source)?;
|
||||
|
||||
for size in THUMBNAIL_SIZES {
|
||||
let ext = match size.format {
|
||||
@@ -143,7 +152,7 @@ pub fn generate_all_thumbnails(
|
||||
} else {
|
||||
80
|
||||
};
|
||||
generate_thumbnail(source, &dest, size, quality)?;
|
||||
generate_thumbnail_from_image(&image, &dest, size, quality)?;
|
||||
paths.push(dest.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
@@ -152,6 +161,10 @@ pub fn generate_all_thumbnails(
|
||||
|
||||
/// Load an image and apply EXIF orientation.
|
||||
fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
|
||||
if is_heic_path(path) {
|
||||
return load_heic(path);
|
||||
}
|
||||
|
||||
let reader = ImageReader::open(path)
|
||||
.map_err(|e| format!("open image: {e}"))?
|
||||
.with_guessed_format()
|
||||
@@ -169,6 +182,135 @@ fn load_and_orient(path: &Path) -> Result<DynamicImage, String> {
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
fn is_heic_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "heic" | "heif"))
|
||||
}
|
||||
|
||||
fn load_heic(path: &Path) -> Result<DynamicImage, String> {
|
||||
let bytes = fs::read(path).map_err(|error| format!("read HEIC image: {error}"))?;
|
||||
let decoded = hpvcd::Decoder::default()
|
||||
.with_decode_gain_map(false)
|
||||
.decode(&bytes)
|
||||
.map_err(|error| format!("decode HEIC image: {error}"))?;
|
||||
let shift = decoded.bit_depth.minus8();
|
||||
let rgb = match decoded.pixels {
|
||||
hpvcd::ImageBuffer::Rgb8(pixels) => pixels,
|
||||
hpvcd::ImageBuffer::Rgb16(samples) => samples
|
||||
.into_iter()
|
||||
.map(|sample| (sample >> shift) as u8)
|
||||
.collect(),
|
||||
hpvcd::ImageBuffer::Luma8(samples) => {
|
||||
samples.into_iter().flat_map(|sample| [sample; 3]).collect()
|
||||
}
|
||||
hpvcd::ImageBuffer::Luma16(samples) => samples
|
||||
.into_iter()
|
||||
.flat_map(|sample| [(sample >> shift) as u8; 3])
|
||||
.collect(),
|
||||
};
|
||||
let mut image = if let Some(alpha) = decoded.alpha {
|
||||
let alpha: Vec<u8> = match alpha {
|
||||
hpvcd::SampleBuf::U8(samples) => samples,
|
||||
hpvcd::SampleBuf::U16(samples) => samples
|
||||
.into_iter()
|
||||
.map(|sample| (sample >> shift) as u8)
|
||||
.collect(),
|
||||
};
|
||||
if alpha.len().checked_mul(3) != Some(rgb.len()) {
|
||||
return Err("decode HEIC image: decoder returned an invalid alpha buffer".to_string());
|
||||
}
|
||||
let rgba = rgb
|
||||
.chunks_exact(3)
|
||||
.zip(alpha)
|
||||
.flat_map(|(rgb, alpha)| [rgb[0], rgb[1], rgb[2], alpha])
|
||||
.collect();
|
||||
DynamicImage::ImageRgba8(
|
||||
RgbaImage::from_raw(decoded.width, decoded.height, rgba).ok_or_else(|| {
|
||||
"decode HEIC image: decoder returned an invalid pixel buffer".to_string()
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
DynamicImage::ImageRgb8(
|
||||
RgbImage::from_raw(decoded.width, decoded.height, rgb).ok_or_else(|| {
|
||||
"decode HEIC image: decoder returned an invalid pixel buffer".to_string()
|
||||
})?,
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(aperture) = decoded.clean_aperture
|
||||
&& let Some((x, y, width, height)) =
|
||||
clean_aperture_crop(decoded.width, decoded.height, decoded.orientation, aperture)
|
||||
{
|
||||
image = image.crop_imm(x, y, width, height);
|
||||
}
|
||||
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
fn clean_aperture_crop(
|
||||
oriented_width: u32,
|
||||
oriented_height: u32,
|
||||
orientation: hpvcd::Orientation,
|
||||
aperture: hpvcd::CleanAperture,
|
||||
) -> Option<(u32, u32, u32, u32)> {
|
||||
let width = aperture.width_pixels()?;
|
||||
let height = aperture.height_pixels()?;
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let swaps_axes = matches!(
|
||||
orientation,
|
||||
hpvcd::Orientation::Rotate90
|
||||
| hpvcd::Orientation::Rotate270
|
||||
| hpvcd::Orientation::Transpose
|
||||
| hpvcd::Orientation::Transverse
|
||||
);
|
||||
let (source_width, source_height) = if swaps_axes {
|
||||
(oriented_height, oriented_width)
|
||||
} else {
|
||||
(oriented_width, oriented_height)
|
||||
};
|
||||
if width > source_width || height > source_height {
|
||||
return None;
|
||||
}
|
||||
|
||||
let horizontal_offset =
|
||||
f64::from(aperture.horiz_off_n) / f64::from(aperture.horiz_off_d.max(1));
|
||||
let vertical_offset = f64::from(aperture.vert_off_n) / f64::from(aperture.vert_off_d.max(1));
|
||||
let source_x = ((f64::from(source_width - width) / 2.0) + horizontal_offset)
|
||||
.round()
|
||||
.clamp(0.0, f64::from(source_width - width)) as u32;
|
||||
let source_y = ((f64::from(source_height - height) / 2.0) + vertical_offset)
|
||||
.round()
|
||||
.clamp(0.0, f64::from(source_height - height)) as u32;
|
||||
|
||||
let crop = match orientation {
|
||||
hpvcd::Orientation::Normal => (source_x, source_y, width, height),
|
||||
hpvcd::Orientation::FlipH => (source_width - source_x - width, source_y, width, height),
|
||||
hpvcd::Orientation::FlipV => (source_x, source_height - source_y - height, width, height),
|
||||
hpvcd::Orientation::Rotate180 => (
|
||||
source_width - source_x - width,
|
||||
source_height - source_y - height,
|
||||
width,
|
||||
height,
|
||||
),
|
||||
hpvcd::Orientation::Rotate90 => {
|
||||
(source_height - source_y - height, source_x, height, width)
|
||||
}
|
||||
hpvcd::Orientation::Rotate270 => (source_y, source_width - source_x - width, height, width),
|
||||
hpvcd::Orientation::Transpose => (
|
||||
source_height - source_y - height,
|
||||
source_width - source_x - width,
|
||||
height,
|
||||
width,
|
||||
),
|
||||
hpvcd::Orientation::Transverse => (source_y, source_x, height, width),
|
||||
};
|
||||
(crop.0 + crop.2 <= oriented_width && crop.1 + crop.3 <= oriented_height).then_some(crop)
|
||||
}
|
||||
|
||||
/// Read EXIF orientation tag from raw file bytes.
|
||||
/// Returns orientation value 1-8, or None if not found.
|
||||
fn read_exif_orientation(data: &[u8]) -> Option<u16> {
|
||||
@@ -268,6 +410,31 @@ fn apply_orientation(img: DynamicImage, orientation: u16) -> DynamicImage {
|
||||
|
||||
/// Extract image dimensions from a file (header-only, no full decode).
|
||||
pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> {
|
||||
if is_heic_path(path) {
|
||||
let bytes = fs::read(path).map_err(|error| format!("read HEIC image: {error}"))?;
|
||||
let info =
|
||||
hpvcd::read_heic_info(&bytes).map_err(|error| format!("HEIC dimensions: {error}"))?;
|
||||
let swaps_axes = matches!(
|
||||
info.orientation,
|
||||
hpvcd::Orientation::Rotate90
|
||||
| hpvcd::Orientation::Rotate270
|
||||
| hpvcd::Orientation::Transpose
|
||||
| hpvcd::Orientation::Transverse
|
||||
);
|
||||
if let Some(aperture) = info.clean_aperture
|
||||
&& let (Some(width), Some(height)) = (aperture.width_pixels(), aperture.height_pixels())
|
||||
&& width > 0
|
||||
&& height > 0
|
||||
{
|
||||
return Ok(if swaps_axes {
|
||||
(height, width)
|
||||
} else {
|
||||
(width, height)
|
||||
});
|
||||
}
|
||||
return Ok((info.width, info.height));
|
||||
}
|
||||
|
||||
let reader = ImageReader::open(path)
|
||||
.map_err(|e| format!("open: {e}"))?
|
||||
.with_guessed_format()
|
||||
@@ -311,6 +478,12 @@ mod tests {
|
||||
path
|
||||
}
|
||||
|
||||
fn heic_fixture() -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("testdata")
|
||||
.join("close.heic")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_small_thumbnail() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -357,6 +530,8 @@ mod tests {
|
||||
assert_eq!(mime_from_extension("jpg"), "image/jpeg");
|
||||
assert_eq!(mime_from_extension("PNG"), "image/png");
|
||||
assert_eq!(mime_from_extension("webp"), "image/webp");
|
||||
assert_eq!(mime_from_extension("HEIC"), "image/heic");
|
||||
assert_eq!(mime_from_extension("heif"), "image/heif");
|
||||
assert_eq!(mime_from_extension("xyz"), "application/octet-stream");
|
||||
}
|
||||
|
||||
@@ -368,4 +543,19 @@ mod tests {
|
||||
assert_eq!(w, 100);
|
||||
assert_eq!(h, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heic_dimensions_and_thumbnails() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = heic_fixture();
|
||||
|
||||
assert_eq!(image_dimensions(&source).unwrap(), (27, 27));
|
||||
|
||||
let paths =
|
||||
generate_all_thumbnails(&source, &dir.path().join("thumbnails"), "heic-test").unwrap();
|
||||
assert_eq!(paths.len(), THUMBNAIL_SIZES.len());
|
||||
assert!(paths.iter().all(|path| Path::new(path).is_file()));
|
||||
assert_eq!(image_dimensions(Path::new(&paths[0])).unwrap(), (27, 27));
|
||||
assert_eq!(image_dimensions(Path::new(&paths[3])).unwrap(), (448, 448));
|
||||
}
|
||||
}
|
||||
|
||||
BIN
crates/bds-core/testdata/close.heic
vendored
Normal file
BIN
crates/bds-core/testdata/close.heic
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user