Add PNG and TIFF support v0.5

This commit is contained in:
dmo committed 2026-07-23 11:23:49 +02:00
1 parent 0541790dba
commit 3acf6bb7ec
10 files changed
+230 -66

No files matched your search

+99 -17
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import io
import json
import logging
import os
@@ -22,12 +23,26 @@ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from PIL import Image, ImageOps, UnidentifiedImageError
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.4.3"
APP_VERSION = "0.5.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"}
TIFF_EXTENSIONS = {".tif", ".tiff"}
MEDIA_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".tif": "image/tiff",
".tiff": "image/tiff",
}
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
MAP_TILE_SOURCE_URL = os.getenv(
@@ -367,7 +382,36 @@ def _normalise_datetime(
return exif_datetime, xmp_date, precision_labels[precision]
async def _save_upload(upload: UploadFile, destination: Path) -> None:
def _format_name(suffix: str) -> str:
if suffix in {".jpg", ".jpeg"}:
return "JPEG"
if suffix == ".png":
return "PNG"
if suffix in TIFF_EXTENSIONS:
return "TIFF"
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)
valid = False
if suffix in {".jpg", ".jpeg"}:
valid = signature.startswith(b"\xff\xd8\xff")
elif suffix == ".png":
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+"))
if not valid:
raise HTTPException(
status_code=415,
detail=f"The uploaded file is not a valid {_format_name(suffix)} image.",
)
async def _save_upload(upload: UploadFile, destination: Path, suffix: str) -> None:
total = 0
with destination.open("wb") as output:
while chunk := await upload.read(1024 * 1024):
@@ -379,10 +423,7 @@ async def _save_upload(upload: UploadFile, destination: Path) -> None:
)
output.write(chunk)
with destination.open("rb") as source:
signature = source.read(3)
if signature[:2] != b"\xff\xd8":
raise HTTPException(status_code=415, detail="The uploaded file is not a valid JPEG image.")
_validate_image_signature(destination, suffix)
def _first_value(metadata: dict[str, Any], *keys: str) -> Any:
@@ -506,16 +547,54 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, An
return has_metadata, edited_by_app, values
@app.post("/api/preview")
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
"""Generate a temporary browser-friendly preview for TIFF 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.")
with tempfile.TemporaryDirectory(prefix="photo-date-editor-preview-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
await _save_upload(file, temp_path, suffix)
try:
with Image.open(temp_path) as source:
source.seek(0)
preview = ImageOps.exif_transpose(source)
preview.thumbnail((PREVIEW_MAX_EDGE, PREVIEW_MAX_EDGE), Image.Resampling.LANCZOS)
if preview.mode in {"RGBA", "LA"} or (preview.mode == "P" and "transparency" in preview.info):
rgba = preview.convert("RGBA")
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
background.alpha_composite(rgba)
preview = background.convert("RGB")
elif preview.mode != "RGB":
preview = preview.convert("RGB")
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
return Response(
content=output.getvalue(),
media_type="image/jpeg",
headers={"Cache-Control": "no-store"},
)
@app.post("/api/metadata")
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, Any]]:
filename = file.filename or "photo.jpg"
suffix = Path(filename).suffix.lower()
if suffix not in {".jpg", ".jpeg"}:
raise HTTPException(status_code=415, detail="This version supports JPG and JPEG files only.")
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(status_code=415, detail="Supported formats are JPG, JPEG, PNG, TIF, and TIFF.")
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
await _save_upload(file, temp_path)
await _save_upload(file, temp_path, suffix)
completed = subprocess.run(
[
"exiftool",
@@ -576,8 +655,8 @@ async def process_photo(
) -> Response:
filename = file.filename or "photo.jpg"
suffix = Path(filename).suffix.lower()
if suffix not in {".jpg", ".jpeg"}:
raise HTTPException(status_code=415, detail="This version supports JPG and JPEG files only.")
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(status_code=415, detail="Supported formats are JPG, JPEG, PNG, TIF, and TIFF.")
exif_datetime, xmp_date, precision_label = _normalise_datetime(
precision=precision,
@@ -606,8 +685,9 @@ async def process_photo(
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
temp_path = Path(temp_dir) / f"working{suffix}"
await _save_upload(file, temp_path)
await _save_upload(file, temp_path, suffix)
supports_iptc = suffix in {".jpg", ".jpeg", ".tif", ".tiff"}
command = [
"exiftool",
"-overwrite_original",
@@ -623,10 +703,10 @@ async def process_photo(
f"-XMP-photoshop:Instructions=Photo Date Editor precision: {precision_label}; time: {time_label}",
"-EXIF:ImageDescription=",
"-XMP-dc:Description=",
"-IPTC:Caption-Abstract=",
"-XMP-dc:Subject=",
"-IPTC:Keywords=",
]
if supports_iptc:
command.extend(["-IPTC:Caption-Abstract=", "-IPTC:Keywords="])
clean_description = description.strip()
if clean_description:
@@ -634,13 +714,15 @@ async def process_photo(
[
f"-EXIF:ImageDescription={clean_description}",
f"-XMP-dc:Description={clean_description}",
f"-IPTC:Caption-Abstract={clean_description}",
]
)
if supports_iptc:
command.append(f"-IPTC:Caption-Abstract={clean_description}")
for keyword in keywords:
command.append(f"-XMP-dc:Subject+={keyword}")
command.append(f"-IPTC:Keywords+={keyword}")
if supports_iptc:
command.append(f"-IPTC:Keywords+={keyword}")
if gps_action == "clear":
command.extend(
@@ -690,7 +772,7 @@ async def process_photo(
return Response(
content=updated_bytes,
media_type="image/jpeg",
media_type=MEDIA_TYPES[suffix],
headers={
"Content-Disposition": f'inline; filename="{Path(filename).name}"',
"Cache-Control": "no-store",