Add configurable formats and lazy decoders, v0.7.1

This commit is contained in:
dmo committed 2026-07-23 21:51:37 +02:00
1 parent b9a0259166
commit 8ed5490afc
8 files changed
+193 -35

No files matched your search

+101 -13
View File
@@ -24,33 +24,78 @@ 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
import rawpy
register_heif_opener()
FORMAT_GROUPS: dict[str, tuple[str, ...]] = {
"jpg": (".jpg", ".jpeg"),
"png": (".png",),
"tiff": (".tif", ".tiff"),
"heif": (".heic", ".heif", ".hif"),
"dng": (".dng",),
}
FORMAT_LABELS = {
"jpg": "JPG",
"png": "PNG",
"tiff": "TIFF",
"heif": "HEIF",
"dng": "DNG",
}
def _enabled_format_groups() -> tuple[str, ...]:
configured = os.getenv("ENABLED_FORMATS", ",".join(FORMAT_GROUPS))
requested = tuple(dict.fromkeys(
value.strip().casefold()
for value in configured.split(",")
if value.strip()
))
unknown = sorted(set(requested) - set(FORMAT_GROUPS))
if unknown:
raise RuntimeError(
"Unknown ENABLED_FORMATS value(s): "
f"{', '.join(unknown)}. Available groups: {', '.join(FORMAT_GROUPS)}."
)
if not requested:
raise RuntimeError("ENABLED_FORMATS must contain at least one format group.")
return tuple(group for group in FORMAT_GROUPS if group in requested)
def _format_list_text(groups: tuple[str, ...]) -> str:
labels = [FORMAT_LABELS[group] for group in groups]
if len(labels) == 1:
return labels[0]
if len(labels) == 2:
return " and ".join(labels)
return f"{', '.join(labels[:-1])} and {labels[-1]}"
ENABLED_FORMAT_GROUPS = _enabled_format_groups()
SUPPORTED_EXTENSIONS = {
extension
for group in ENABLED_FORMAT_GROUPS
for extension in FORMAT_GROUPS[group]
}
ENABLED_FORMAT_LABEL = _format_list_text(ENABLED_FORMAT_GROUPS)
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
APP_TITLE = os.getenv("APP_TITLE") or APP_NAME
APP_SUBTITLE = (
os.getenv("APP_SUBTITLE")
or "Local folder · in-place JPG, PNG, TIFF and HEIF metadata"
or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata"
)
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.7.0"
APP_VERSION = "0.7.1"
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",
".heic", ".heif", ".hif", ".dng",
}
TIFF_EXTENSIONS = {".tif", ".tiff"}
HEIF_EXTENSIONS = {".heic", ".heif", ".hif"}
DNG_EXTENSIONS = {".dng"}
SERVER_PREVIEW_EXTENSIONS = TIFF_EXTENSIONS | HEIF_EXTENSIONS | DNG_EXTENSIONS
SERVER_PREVIEW_EXTENSIONS = (
TIFF_EXTENSIONS | HEIF_EXTENSIONS | DNG_EXTENSIONS
) & SUPPORTED_EXTENSIONS
MEDIA_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
@@ -87,6 +132,9 @@ _last_geocode_request = 0.0
_tile_cache: OrderedDict[str, tuple[bytes, str]] = OrderedDict()
_tile_cache_limit = 512
_tile_lock = asyncio.Lock()
_decoder_lock = threading.Lock()
_heif_decoder_registered = False
_rawpy_module: Any | None = None
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
@@ -116,7 +164,7 @@ def health() -> dict[str, str]:
@app.get("/api/config")
def config() -> dict[str, str | int | float]:
def config() -> dict[str, Any]:
return {
"appName": APP_NAME,
"appTitle": APP_TITLE,
@@ -124,6 +172,9 @@ def config() -> dict[str, str | int | float]:
"appUrl": APP_URL,
"version": APP_VERSION,
"maxUploadMb": MAX_UPLOAD_MB,
"enabledFormats": list(ENABLED_FORMAT_GROUPS),
"enabledExtensions": sorted(SUPPORTED_EXTENSIONS),
"enabledFormatLabel": ENABLED_FORMAT_LABEL,
"mapTileUrl": "/api/map/tiles/{z}/{x}/{y}.png",
"mapAttribution": MAP_ATTRIBUTION,
"defaultMapLat": DEFAULT_MAP_LAT,
@@ -585,6 +636,35 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, An
return has_metadata, edited_by_app, values
def _ensure_heif_decoder() -> None:
"""Register pillow-heif once, on the first HEIF preview request."""
global _heif_decoder_registered
if _heif_decoder_registered:
return
with _decoder_lock:
if _heif_decoder_registered:
return
from pillow_heif import register_heif_opener
register_heif_opener()
_heif_decoder_registered = True
logger.info("HEIF preview decoder loaded.")
def _get_rawpy() -> Any:
"""Import rawpy once, on the first DNG that needs a rendered fallback."""
global _rawpy_module
if _rawpy_module is not None:
return _rawpy_module
with _decoder_lock:
if _rawpy_module is None:
import rawpy
_rawpy_module = rawpy
logger.info("DNG LibRaw preview decoder loaded.")
return _rawpy_module
def _embedded_dng_preview(path: Path) -> Image.Image | None:
"""Return the first usable DNG preview, preferring the largest common tags."""
for tag in ("JpgFromRaw", "PreviewImage", "ThumbnailImage"):
@@ -611,6 +691,7 @@ def _embedded_dng_preview(path: Path) -> Image.Image | None:
def _render_dng(path: Path) -> Image.Image:
"""Render a half-size RGB fallback when a DNG has no embedded JPEG preview."""
rawpy = _get_rawpy()
try:
with rawpy.imread(str(path)) as raw:
width = int(raw.sizes.width)
@@ -634,6 +715,11 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
"""Generate a temporary browser-friendly preview for TIFF, HEIF, and DNG images."""
filename = file.filename or "photo.tiff"
suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(
status_code=415,
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
)
if suffix not in SERVER_PREVIEW_EXTENSIONS:
raise HTTPException(
status_code=415,
@@ -650,6 +736,8 @@ async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
preview = _render_dng(temp_path)
preview = ImageOps.exif_transpose(preview)
else:
if suffix in HEIF_EXTENSIONS:
_ensure_heif_decoder()
with Image.open(temp_path) as source:
source.seek(0)
preview = ImageOps.exif_transpose(source)
@@ -692,7 +780,7 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(
status_code=415,
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, HIF, and DNG.",
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
)
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
@@ -761,7 +849,7 @@ async def process_photo(
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(
status_code=415,
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, HIF, and DNG.",
detail=f"This format is disabled. Enabled format groups: {ENABLED_FORMAT_LABEL}.",
)
exif_datetime, xmp_date, precision_label = _normalise_datetime(