Added HEIC, HEIF and HIF support v0.6

This commit is contained in:
dmo committed 2026-07-23 12:59:49 +02:00
1 parent 3acf6bb7ec
commit 47b8d42295
9 files changed
+83 -32

No files matched your search

+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) => {