Optimize RAW inspection and add timings v0.8.1
This commit is contained in:
1 parent
4e3c442b26
commit
d17bc6a132
6 files changed
+200
-122
No files matched your search
+125
-111
@@ -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}'
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user