Support HEIC media imports

This commit is contained in:
2026-07-22 18:49:27 +02:00
parent bdfe57b66f
commit e9f2cdb25d
10 changed files with 335 additions and 26 deletions

7
Cargo.lock generated
View File

@@ -932,6 +932,7 @@ dependencies = [
"fastembed",
"fluent-bundle",
"fluent-syntax",
"hpvcd",
"htmd",
"image 0.25.10",
"keyring",
@@ -4047,6 +4048,12 @@ version = "1.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
[[package]]
name = "hpvcd"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f06638af0669411fb9ab793bb86c534d560bf8b384fb48975afc82a52cdd341"
[[package]]
name = "htmd"
version = "0.5.4"

View File

@@ -34,6 +34,7 @@ axum = "0.8"
walkdir = "2"
futures = "0.3"
image = "0.25"
hpvcd = "0.3.1"
webp = { version = "0.3.1", default-features = false }
rust-stemmers = "1"
sys-locale = "0.3"

View File

@@ -150,7 +150,7 @@ The Media section is where you import, describe, and maintain assets used by pos
When importing media, add metadata while context is still fresh. Alt text should describe meaning for accessibility. Captions should support reader understanding. Media tags should help later retrieval and reuse.
From the post editor you can insert already-imported media at the cursor position, drag supported image files onto an open post editor, or use the Gallery workflow to import several images at once. A dropped image is imported into the media library, linked to the current post, and inserted at the current cursor with a host-absolute `/media/...` URL that remains valid in rendered HTML. Multiple dropped files are processed in order with progress in the Tasks panel. Optional AI enrichment and media-metadata translation continue in the background; when the active AI endpoint is unavailable, the import still completes and RuDS reports that enrichment was skipped.
From the post editor you can insert already-imported media at the cursor position, drag supported image files—including HEIC and HEIF photos—onto an open post editor, or use the Gallery workflow to import several images at once. A dropped image is imported into the media library, linked to the current post, and inserted at the current cursor with a host-absolute `/media/...` URL that remains valid in rendered HTML. Multiple dropped files are processed in order with progress in the Tasks panel. Optional AI enrichment and media-metadata translation continue in the background; when the active AI endpoint is unavailable, the import still completes and RuDS reports that enrichment was skipped.
The Gallery workflow imports its selected images into the media library, links them to the current post, runs the same optional AI enrichment for titles and alt text, and inserts the gallery block into the post.

View File

@@ -8,7 +8,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars, relative-dated entity lists with row deletion, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor.
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import.
- Media import, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
- Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
- SQLite and filesystem persistence with frontmatter, relationship-safe media sidecars, rebuild, bidirectional metadata diff/repair including project categories and publishing preferences, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search.

View File

@@ -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 => {

View File

@@ -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 }

View File

@@ -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();

View File

@@ -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)?;

View File

@@ -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

Binary file not shown.