Optimize RAW inspection and add timings v0.8.1

This commit is contained in:
dmo committed 2026-07-25 08:35:22 +02:00
1 parent 4e3c442b26
commit d17bc6a132
6 files changed
+200 -122

No files matched your search

+125 -111
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import base64
import io
import json
import logging
@@ -85,10 +86,11 @@ APP_SUBTITLE = (
or f"Local folder · in-place {ENABLED_FORMAT_LABEL} metadata"
)
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.8.0"
APP_VERSION = "0.8.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"))
RAW_PREVIEW_MAX_EDGE = int(os.getenv("RAW_PREVIEW_MAX_EDGE", "1600"))
PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000"))
Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
@@ -689,11 +691,17 @@ def _get_rawpy() -> Any:
return _rawpy_module
def _embedded_raw_preview(path: Path, format_name: str) -> Image.Image | None:
"""Extract the largest usable JPEG from the common RAW preview tags."""
best: Image.Image | None = None
best_area = 0
for tag in ("JpgFromRaw", "PreviewImage", "ThumbnailImage"):
def _raw_preview_tags(suffix: str) -> tuple[str, ...]:
if suffix == ".cr2":
return ("PreviewImage", "JpgFromRaw", "ThumbnailImage")
if suffix == ".cr3":
return ("JpgFromRaw", "PreviewImage", "ThumbnailImage")
return ("JpgFromRaw", "PreviewImage", "ThumbnailImage")
def _embedded_raw_preview(path: Path, format_name: str, suffix: str) -> Image.Image | None:
"""Return the first usable embedded JPEG, ordered for the RAW container."""
for tag in _raw_preview_tags(suffix):
try:
completed = subprocess.run(
["exiftool", "-b", f"-{tag}", "--", str(path)],
@@ -709,18 +717,12 @@ def _embedded_raw_preview(path: Path, format_name: str) -> Image.Image | None:
try:
with Image.open(io.BytesIO(completed.stdout)) as source:
source.load()
candidate = source.copy()
area = candidate.width * candidate.height
if area > best_area:
if best is not None:
best.close()
best = candidate
best_area = area
else:
candidate.close()
preview = source.copy()
logger.info("Using %s for %s preview.", tag, format_name)
return preview
except (UnidentifiedImageError, OSError, ValueError):
logger.debug("%s %s data was not a usable image preview.", format_name, tag)
return best
return None
def _render_raw(path: Path, format_name: str) -> Image.Image:
@@ -746,68 +748,101 @@ def _render_raw(path: Path, format_name: str) -> Image.Image:
return Image.fromarray(rgb)
def _preview_bytes(temp_path: Path, suffix: str, filename: str) -> bytes:
try:
if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS:
format_name = _format_name(suffix)
preview = _embedded_raw_preview(temp_path, format_name, suffix)
if preview is None:
preview = _render_raw(temp_path, format_name)
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)
preview.load()
try:
max_edge = RAW_PREVIEW_MAX_EDGE if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS else PREVIEW_MAX_EDGE
preview.thumbnail((max_edge, 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=86, optimize=True)
return output.getvalue()
finally:
preview.close()
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as 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
def _read_metadata_path(temp_path: Path) -> dict[str, bool | dict[str, Any]]:
completed = subprocess.run(
[
"exiftool", "-json", "-s", "-n",
"-DateTimeOriginal", "-CreateDate", "-ModifyDate", "-DateCreated",
"-Instructions", "-ImageDescription", "-Description", "-Caption-Abstract",
"-Subject", "-Keywords", "-GPSLatitude", "-GPSLatitudeRef",
"-GPSLongitude", "-GPSLongitudeRef", "-Location",
"-LocationShownLocationName", "-City", str(temp_path),
],
capture_output=True, text=True, timeout=45, check=False,
)
if completed.returncode != 0:
logger.error("ExifTool metadata read failed: %s", completed.stderr.strip())
raise HTTPException(status_code=500, detail="ExifTool could not read this image's metadata.")
try:
output = json.loads(completed.stdout)
metadata = output[0] if isinstance(output, list) and output else {}
except json.JSONDecodeError as exc:
raise HTTPException(status_code=500, detail="ExifTool returned invalid metadata output.") from exc
has_metadata, edited_by_app, values = _metadata_values(metadata)
return {"hasMetadata": has_metadata, "editedByApp": edited_by_app, "values": values}
@app.post("/api/preview")
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
"""Generate a browser-friendly preview for TIFF, HEIF, DNG, CR2, and CR3."""
"""Generate a browser-friendly preview for server-preview formats."""
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,
detail="Server-generated previews are only required for TIFF, HEIF, DNG, CR2, and CR3 files.",
)
if suffix not in SUPPORTED_EXTENSIONS or suffix not in SERVER_PREVIEW_EXTENSIONS:
raise HTTPException(status_code=415, detail="This format is disabled or does not require a server preview.")
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:
if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS:
format_name = _format_name(suffix)
preview = _embedded_raw_preview(temp_path, format_name)
if preview is None:
preview = _render_raw(temp_path, format_name)
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)
preview.load()
preview_bytes = _preview_bytes(temp_path, suffix, filename)
return Response(content=preview_bytes, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
try:
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)
finally:
preview.close()
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as 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(),
media_type="image/jpeg",
headers={"Cache-Control": "no-store"},
)
@app.post("/api/inspect")
async def inspect_photo(file: Annotated[UploadFile, File(...)]) -> dict[str, Any]:
"""Upload once and return both metadata and a browser-friendly preview."""
filename = file.filename or "photo.tiff"
suffix = Path(filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS or suffix not in SERVER_PREVIEW_EXTENSIONS:
raise HTTPException(status_code=415, detail="This format is disabled or does not require server inspection.")
with tempfile.TemporaryDirectory(prefix="photo-date-editor-inspect-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
await _save_upload(file, temp_path, suffix)
preview_bytes = _preview_bytes(temp_path, suffix, filename)
metadata = _read_metadata_path(temp_path)
return {
**metadata,
"previewBase64": base64.b64encode(preview_bytes).decode("ascii"),
"previewMediaType": "image/jpeg",
}
@app.post("/api/metadata")
@@ -823,47 +858,8 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
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, suffix)
completed = subprocess.run(
[
"exiftool",
"-json",
"-s",
"-n",
"-DateTimeOriginal",
"-CreateDate",
"-ModifyDate",
"-DateCreated",
"-Instructions",
"-ImageDescription",
"-Description",
"-Caption-Abstract",
"-Subject",
"-Keywords",
"-GPSLatitude",
"-GPSLatitudeRef",
"-GPSLongitude",
"-GPSLongitudeRef",
"-Location",
"-LocationShownLocationName",
"-City",
str(temp_path),
],
capture_output=True,
text=True,
timeout=45,
check=False,
)
if completed.returncode != 0:
logger.error("ExifTool metadata read failed: %s", completed.stderr.strip())
raise HTTPException(status_code=500, detail="ExifTool could not read this image's metadata.")
try:
output = json.loads(completed.stdout)
metadata = output[0] if isinstance(output, list) and output else {}
except json.JSONDecodeError as exc:
raise HTTPException(status_code=500, detail="ExifTool returned invalid metadata output.") from exc
has_metadata, edited_by_app, values = _metadata_values(metadata)
return {"hasMetadata": has_metadata, "editedByApp": edited_by_app, "values": values}
result = _read_metadata_path(temp_path)
return result
@app.post("/api/process")
@@ -916,7 +912,9 @@ async def process_photo(
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
temp_path = Path(temp_dir) / f"working{suffix}"
upload_started = time.perf_counter()
await _save_upload(file, temp_path, suffix)
upload_seconds = time.perf_counter() - upload_started
supports_iptc = suffix in IPTC_EXTENSIONS
command = [
@@ -988,18 +986,29 @@ async def process_photo(
command.append(str(temp_path))
logger.info("Processing %s with precision %s", filename, precision)
exiftool_started = time.perf_counter()
completed = subprocess.run(
command,
capture_output=True,
text=True,
timeout=90,
timeout=180 if suffix in DNG_EXTENSIONS | CANON_EXTENSIONS else 90,
check=False,
)
exiftool_seconds = time.perf_counter() - exiftool_started
if completed.returncode != 0:
logger.error("ExifTool failed: %s", completed.stderr.strip())
raise HTTPException(status_code=500, detail="ExifTool could not update this image.")
response_started = time.perf_counter()
updated_bytes = temp_path.read_bytes()
response_read_seconds = time.perf_counter() - response_started
logger.info(
"Processed %s: upload %.2fs, ExifTool %.2fs, response read %.2fs",
filename,
upload_seconds,
exiftool_seconds,
response_read_seconds,
)
return Response(
content=updated_bytes,
@@ -1008,6 +1017,11 @@ async def process_photo(
"Content-Disposition": f'inline; filename="{Path(filename).name}"',
"Cache-Control": "no-store",
"X-Date-Precision": precision_label,
"Server-Timing": (
f'upload;dur={upload_seconds * 1000:.1f}, '
f'exiftool;dur={exiftool_seconds * 1000:.1f}, '
f'read;dur={response_read_seconds * 1000:.1f}'
),
},
)