Added HEIC, HEIF and HIF support v0.6

This commit is contained in:
dmo
2026-07-23 12:59:49 +02:00
parent 3acf6bb7ec
commit 47b8d42295
9 changed files with 83 additions and 32 deletions
+13
View File
@@ -1,5 +1,18 @@
# Changelog
## 0.6.0
- Add HEIC, HEIF, and HIF folder scanning and in-place metadata processing.
- Normalize all three HEIF-family extensions to a `HEIF` format badge and include them in format sorting.
- Generate temporary browser-friendly JPEG previews for HEIF-family images while preserving and overwriting the original container on save.
- Add `pillow-heif` and the container HEIF runtime needed to decode previews.
- Validate HEIF-family files as ISO Base Media File Format containers with recognized HEVC image brands before processing, without admitting renamed AVIF files.
- Read and write HEIF EXIF/XMP dates, precision marker, description, keywords, GPS coordinates, and location label without applying IPTC-IIM fields.
- Return updated files using HEIC/HEIF media types while keeping the original filename and extension.
- Update the interface, manifest, documentation, and empty-folder guidance for the new formats.
- Preserve the established JPG, PNG, and TIFF processing paths.
- Bump the application and service-worker cache version to 0.6.0.
## 0.5.0
- Add in-place PNG metadata reading and writing.
+1 -1
View File
@@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates libimage-exiftool-perl \
&& apt-get install -y --no-install-recommends ca-certificates libheif1 libimage-exiftool-perl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
+15 -13
View File
@@ -1,12 +1,12 @@
# Photo Date Editor
Version 0.5.0
Version 0.6.0
A self-hosted browser UI for manually dating scanned photographs. The browser receives temporary read/write access to a local computer or Chromebook folder, sends one image at a time to the Docker backend for ExifTool processing, and overwrites the same local file after processing.
## Current scope
- JPG/JPEG, PNG, and TIFF/TIF
- JPG/JPEG, PNG, TIFF/TIF, and HEIC/HEIF/HIF
- Local directory picker in supported Chromium browsers
- Three-column photo workflow
- Exact date, month/year, year-only, approximate year
@@ -17,7 +17,7 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
- In-place overwrite: no browser download and no `_original` file
- Saved/skipped/pending/failed state stored in browser local storage
- Filename search, status filtering, and filename/status/format sorting
- Normalized JPG, PNG, and TIFF format badges in the photo list
- Normalized JPG, PNG, TIFF, and HEIF format badges in the photo list
- Segmented progress bar: saved is green, skipped is yellow, failed is red, and pending remains grey
- Existing metadata is read from supported files when selected
- Files previously edited by this app are recognized from their XMP marker, even in a different browser
@@ -25,9 +25,9 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
## Preview behavior
JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF, so TIFF files are temporarily uploaded to `/api/preview` and rendered as a JPEG preview by Pillow. This preview exists only in memory and is never written over the source file.
JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF or HEIF-family images, so TIFF, HEIC, HEIF, and HIF files are temporarily uploaded to `/api/preview` and rendered as a JPEG preview by Pillow with `pillow-heif`. This preview exists only in memory and is never written over the source file.
When a TIFF is saved, ExifTool updates the original TIFF and the browser overwrites the same local `.tif` or `.tiff` file. The file is not converted to JPEG and no additional copy is intentionally left behind.
When a TIFF or HEIF-family image is saved, ExifTool updates the original format and the browser overwrites the same local file. The original is not converted to JPEG and no additional copy is intentionally left behind.
## Requirements
@@ -55,7 +55,7 @@ APP_NAME=Photo Date Editor
MAX_UPLOAD_MB=150
LOG_LEVEL=INFO
# Optional TIFF preview limits
# Optional TIFF/HEIF preview limits
PREVIEW_MAX_EDGE=2400
PREVIEW_MAX_PIXELS=300000000
@@ -70,7 +70,7 @@ GEOCODER_URL=https://nominatim.openstreetmap.org/search
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
## Upgrade from 0.1 through 0.4.3
## Upgrade from 0.1 through 0.5
Replace the project files with this version and rebuild:
@@ -100,7 +100,7 @@ The browser directory picker requires a secure context. Use a certificate truste
## What happens when Save is clicked
1. The browser reads the selected local JPG, PNG, or TIFF.
1. The browser reads the selected local JPG, PNG, TIFF, HEIC, HEIF, or HIF.
2. It sends the file and entered fields to `/api/process`.
3. ExifTool modifies a temporary file inside the container using `-overwrite_original`.
4. The backend returns the modified file using its original media type.
@@ -114,6 +114,7 @@ No second photo is intentionally left on the client computer or Docker host.
- **JPG/JPEG:** EXIF, XMP, and IPTC fields are written.
- **TIFF/TIF:** EXIF, XMP, and IPTC fields are written.
- **PNG:** EXIF and XMP fields are written. PNG metadata support varies more between third-party viewers than JPEG/TIFF support, so test the applications that will consume the files.
- **HEIC/HEIF/HIF:** EXIF and XMP fields are written. IPTC-IIM is not used for these ISO Base Media File Format containers. Date precision, description, keywords, GPS coordinates, and the location label remain represented through EXIF/XMP fields.
The app writes its precision marker to XMP for all supported formats. GPS coordinates are written to EXIF and XMP. The optional location label is written to XMP IPTC Core Location.
@@ -161,7 +162,9 @@ The default map tiles and address search are external OpenStreetMap services. Bo
- Overwriting a file through the browser generally changes its filesystem modified time to the time of the save. EXIF/XMP photo dates are independent of that filesystem timestamp.
- Preview rotation is visual only; it does not rotate image pixels or write orientation metadata.
- Multi-page TIFF files display the first page/frame in the editor. Metadata is written to the TIFF container, not to separate pages.
- Very large TIFF files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`.
- HEIF image sequences display their primary/first image in the editor. Metadata is written to the HEIF container.
- Very large TIFF or HEIF files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`.
- HEIF metadata compatibility varies between operating-system galleries and photo-management applications. Verify the fields in the software that will consume your library.
- The Docker host needs outbound HTTPS access for the default map tiles and address search.
- Test with copies first, then use your normal backup routine for the originals.
@@ -171,10 +174,9 @@ Format support needs both safe metadata writing and a browser-friendly preview.
### Proposed order
1. **HEIC/HEIF/HIF** — in-place EXIF/XMP writing and a server-generated preview.
2. **DNG** — in-place metadata writing and preview extraction/rendering.
3. **Canon RAW**`CR2` and `CR3`, using per-format tag rules and embedded/server-generated previews.
4. **Nikon RAW**`NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews.
1. **DNG** — in-place metadata writing and preview extraction/rendering.
2. **Canon RAW**`CR2` and `CR3`, using per-format tag rules and embedded/server-generated previews.
3. **Nikon RAW**`NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews.
`RAW` is not treated as one universal format. Each camera family will be enabled and tested explicitly.
+42 -10
View File
@@ -24,24 +24,32 @@ from pydantic import BaseModel, Field
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from PIL import Image, ImageOps, UnidentifiedImageError
from pillow_heif import register_heif_opener
register_heif_opener()
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.5.0"
APP_VERSION = "0.6.0"
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
PREVIEW_MAX_EDGE = int(os.getenv("PREVIEW_MAX_EDGE", "2400"))
PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000"))
Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff"}
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".heic", ".heif", ".hif"}
TIFF_EXTENSIONS = {".tif", ".tiff"}
HEIF_EXTENSIONS = {".heic", ".heif", ".hif"}
SERVER_PREVIEW_EXTENSIONS = TIFF_EXTENSIONS | HEIF_EXTENSIONS
MEDIA_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".tif": "image/tiff",
".tiff": "image/tiff",
".heic": "image/heic",
".heif": "image/heif",
".hif": "image/heif",
}
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -389,12 +397,14 @@ def _format_name(suffix: str) -> str:
return "PNG"
if suffix in TIFF_EXTENSIONS:
return "TIFF"
if suffix in HEIF_EXTENSIONS:
return "HEIF"
return suffix.lstrip(".").upper() or "image"
def _validate_image_signature(path: Path, suffix: str) -> None:
with path.open("rb") as source:
signature = source.read(16)
signature = source.read(64)
valid = False
if suffix in {".jpg", ".jpeg"}:
@@ -403,6 +413,15 @@ def _validate_image_signature(path: Path, suffix: str) -> None:
valid = signature.startswith(b"\x89PNG\r\n\x1a\n")
elif suffix in TIFF_EXTENSIONS:
valid = signature.startswith((b"II*\x00", b"MM\x00*", b"II+\x00", b"MM\x00+"))
elif suffix in HEIF_EXTENSIONS:
hevc_brands = {
b"heic", b"heix", b"hevc", b"hevx",
b"heim", b"heis", b"hevm", b"hevs",
}
valid = len(signature) >= 16 and signature[4:8] == b"ftyp" and any(
signature[offset:offset + 4] in hevc_brands
for offset in range(8, len(signature) - 3, 4)
)
if not valid:
raise HTTPException(
@@ -549,11 +568,14 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, An
@app.post("/api/preview")
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
"""Generate a temporary browser-friendly preview for TIFF images."""
"""Generate a temporary browser-friendly preview for TIFF and HEIF images."""
filename = file.filename or "photo.tiff"
suffix = Path(filename).suffix.lower()
if suffix not in TIFF_EXTENSIONS:
raise HTTPException(status_code=415, detail="Server-generated previews are only required for TIFF files.")
if suffix not in SERVER_PREVIEW_EXTENSIONS:
raise HTTPException(
status_code=415,
detail="Server-generated previews are only required for TIFF and HEIF files.",
)
with tempfile.TemporaryDirectory(prefix="photo-date-editor-preview-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
@@ -575,8 +597,12 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
output = io.BytesIO()
preview.save(output, format="JPEG", quality=88, optimize=True)
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
logger.warning("TIFF preview generation failed for %s: %s", filename, exc)
raise HTTPException(status_code=422, detail="The TIFF could not be rendered for preview.") from exc
format_name = _format_name(suffix)
logger.warning("%s preview generation failed for %s: %s", format_name, filename, exc)
raise HTTPException(
status_code=422,
detail=f"The {format_name} image could not be rendered for preview.",
) from exc
return Response(
content=output.getvalue(),
@@ -590,7 +616,10 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
filename = file.filename or "photo.jpg"
suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(status_code=415, detail="Supported formats are JPG, JPEG, PNG, TIF, and TIFF.")
raise HTTPException(
status_code=415,
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, and HIF.",
)
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
@@ -656,7 +685,10 @@ async def process_photo(
filename = file.filename or "photo.jpg"
suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(status_code=415, detail="Supported formats are JPG, JPEG, PNG, TIF, and TIFF.")
raise HTTPException(
status_code=415,
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, and HIF.",
)
exif_datetime, xmp_date, precision_label = _normalise_datetime(
precision=precision,
+7 -4
View File
@@ -118,11 +118,12 @@ function formatType(filename) {
if (extension === 'jpg' || extension === 'jpeg') return 'JPG';
if (extension === 'png') return 'PNG';
if (extension === 'tif' || extension === 'tiff') return 'TIFF';
if (extension === 'heic' || extension === 'heif' || extension === 'hif') return 'HEIF';
return extension.toUpperCase();
}
function requiresServerPreview(photo) {
return photo.format === 'TIFF';
return photo.format === 'TIFF' || photo.format === 'HEIF';
}
function storageKey() {
@@ -176,7 +177,7 @@ async function scanFolder() {
const photos = [];
for await (const [name, handle] of state.directoryHandle.entries()) {
if (handle.kind !== 'file' || !/\.(jpe?g|png|tiff?)$/i.test(name)) continue;
if (handle.kind !== 'file' || !/\.(jpe?g|png|tiff?|heic|heif|hif)$/i.test(name)) continue;
const file = await handle.getFile();
const previous = stored.photos?.[name] || {};
const unchanged = !previous.lastModified || previous.lastModified === file.lastModified;
@@ -215,7 +216,7 @@ async function scanFolder() {
} else {
elements.viewerContent.classList.add('hidden');
elements.viewerEmpty.classList.remove('hidden');
showToast('No JPG, PNG, or TIFF files were found in that folder.', true);
showToast('No JPG, PNG, TIFF, HEIC, HEIF, or HIF files were found in that folder.', true);
}
}
@@ -398,7 +399,9 @@ async function selectPhoto(index, force = false) {
active?.scrollIntoView({ block: 'nearest' });
try {
if (requiresServerPreview(photo)) setSaveStatus('loading', 'Rendering TIFF preview…', photo.name);
if (requiresServerPreview(photo)) {
setSaveStatus('loading', `Rendering ${photo.format} preview…`, photo.name);
}
const previewUrl = await getPreviewUrl(photo, file);
if (selectionToken !== state.selectionToken || state.currentIndex !== index) return;
elements.preview.src = previewUrl;
+2 -2
View File
@@ -16,7 +16,7 @@
<div class="brand-mark" aria-hidden="true"></div>
<div>
<h1 id="app-name">Photo Date Editor</h1>
<p>Local folder · in-place JPG, PNG and TIFF metadata</p>
<p>Local folder · in-place JPG, PNG, TIFF and HEIF metadata</p>
</div>
</div>
<div class="top-actions">
@@ -74,7 +74,7 @@
</select>
</div>
<div id="file-list" class="file-list" aria-live="polite">
<div class="empty-list">Choose a folder containing JPG, PNG, or TIFF photos.</div>
<div class="empty-list">Choose a folder containing JPG, PNG, TIFF, HEIC, HEIF, or HIF photos.</div>
</div>
</section>
+1 -1
View File
@@ -5,5 +5,5 @@
"display": "standalone",
"background_color": "#0d1218",
"theme_color": "#121820",
"description": "Edit dates, descriptions, keywords, and locations in scanned JPG, PNG, and TIFF photos."
"description": "Edit dates, descriptions, keywords, and locations in JPG, PNG, TIFF, and HEIF photos."
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v5-0';
const CACHE_NAME = 'photo-date-editor-v6-0';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {
+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.116,<1
uvicorn[standard]>=0.35,<1
python-multipart>=0.0.20,<1
Pillow>=11.3,<13
pillow-heif>=1.1,<2