Date photo uploads from capture metadata
This commit is contained in:
+93
-10
@@ -15,7 +15,7 @@ import warnings
|
||||
import zipfile
|
||||
|
||||
from flask import Flask, abort, jsonify, request
|
||||
from PIL import Image, ImageCms, ImageOps, UnidentifiedImageError
|
||||
from PIL import ExifTags, Image, ImageCms, ImageOps, UnidentifiedImageError
|
||||
from pillow_heif import register_heif_opener
|
||||
from werkzeug.exceptions import HTTPException
|
||||
from posts import parse_upload
|
||||
@@ -27,6 +27,71 @@ GALLERY_MAX_BYTES = 300 * 1024
|
||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||
|
||||
|
||||
def exif_photo_date(exif):
|
||||
"""Return the original capture time without exposing or retaining other metadata."""
|
||||
try:
|
||||
nested = exif.get_ifd(ExifTags.IFD.Exif)
|
||||
except (AttributeError, KeyError, TypeError, ValueError):
|
||||
nested = {}
|
||||
|
||||
def value(tag):
|
||||
candidate = nested.get(tag, exif.get(tag))
|
||||
if isinstance(candidate, bytes):
|
||||
candidate = candidate.decode("ascii", "ignore")
|
||||
return str(candidate or "").strip(" \0")
|
||||
|
||||
# Prefer when the shutter fired, then digitization time, then the generic
|
||||
# image timestamp. Offset and subseconds belong to their matching date tag.
|
||||
for date_tag, offset_tag, subsecond_tag in (
|
||||
(36867, 36881, 37521), (36868, 36882, 37522), (306, 36880, 37520)):
|
||||
raw_date = value(date_tag)
|
||||
if not raw_date:
|
||||
continue
|
||||
try:
|
||||
taken = datetime.strptime(raw_date, "%Y:%m:%d %H:%M:%S")
|
||||
except ValueError:
|
||||
continue
|
||||
subseconds = re.sub(r"\D", "", value(subsecond_tag))[:6]
|
||||
if subseconds:
|
||||
taken = taken.replace(microsecond=int(subseconds.ljust(6, "0")))
|
||||
offset = value(offset_tag)
|
||||
if re.fullmatch(r"[+-]\d{2}:\d{2}", offset):
|
||||
sign = 1 if offset[0] == "+" else -1
|
||||
hours, minutes = map(int, offset[1:].split(":"))
|
||||
taken = taken.replace(tzinfo=timezone(sign * timedelta(hours=hours, minutes=minutes)))
|
||||
return taken
|
||||
return None
|
||||
|
||||
|
||||
def submitted_photo_dates(raw, count):
|
||||
"""Parse newline-separated ISO dates supplied from the Photos library."""
|
||||
if not raw.strip():
|
||||
return None
|
||||
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
if len(lines) != count:
|
||||
raise ValueError("photo_dates must contain one ISO date per photo")
|
||||
dates = []
|
||||
for line in lines:
|
||||
try:
|
||||
dates.append(datetime.fromisoformat(line.replace("Z", "+00:00")))
|
||||
except ValueError as error:
|
||||
raise ValueError("photo_dates must contain valid ISO dates") from error
|
||||
return dates
|
||||
|
||||
|
||||
def distinct_photo_dates(dates):
|
||||
"""Keep exact capture times, using earlier seconds only to break true ties."""
|
||||
result = []
|
||||
used = set()
|
||||
for taken in dates:
|
||||
candidate = taken
|
||||
while candidate.isoformat() in used:
|
||||
candidate -= timedelta(seconds=1)
|
||||
result.append(candidate)
|
||||
used.add(candidate.isoformat())
|
||||
return result
|
||||
|
||||
|
||||
def atomic_json(path, value):
|
||||
fd, temporary = tempfile.mkstemp(dir=path.parent)
|
||||
try:
|
||||
@@ -48,6 +113,7 @@ def web_image(raw, destination):
|
||||
with Image.open(io.BytesIO(raw)) as original:
|
||||
if original.format not in {"JPEG", "PNG", "WEBP", "HEIF"}:
|
||||
raise ValueError("Use JPEG, PNG, WebP, or HEIC photos")
|
||||
taken_at = exif_photo_date(original.getexif())
|
||||
image = ImageOps.exif_transpose(original)
|
||||
image.thumbnail((GALLERY_MAX_EDGE, GALLERY_MAX_EDGE), Image.Resampling.LANCZOS)
|
||||
if image.info.get("icc_profile"):
|
||||
@@ -74,17 +140,14 @@ def web_image(raw, destination):
|
||||
clean.save(output, "JPEG", quality=quality, optimize=True, progressive=True)
|
||||
if output.tell() <= GALLERY_MAX_BYTES:
|
||||
Path(destination).write_bytes(output.getvalue())
|
||||
return
|
||||
return taken_at
|
||||
clean.thumbnail((max(1, int(clean.width * 0.85)), max(1, int(clean.height * 0.85))),
|
||||
Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def photo_entry(job, date, index, count, caption, highlight):
|
||||
# Give each photo its own collection document. Descending dates preserve the
|
||||
# selection order because the gallery renders the newest document first.
|
||||
# Jekyll's collection ordering only preserves whole seconds. Give each item
|
||||
# a distinct second so filenames cannot disturb the selected photo order.
|
||||
item_date = datetime.fromisoformat(date) + timedelta(seconds=count - index)
|
||||
def photo_entry(job, date, index, caption, highlight):
|
||||
# Each photo carries its own capture date so the gallery is chronological.
|
||||
item_date = datetime.fromisoformat(date)
|
||||
title = f"photo-{job}" if index == 0 else f"photo-{job}-{index:02}"
|
||||
content = ["---", "layout: post", f'title: "{title}"',
|
||||
f'date: "{item_date.isoformat()}"', "categories: art"]
|
||||
@@ -255,6 +318,11 @@ def create_app(data_dir=None, token=None):
|
||||
highlight = request.form.get("highlight", "").strip()
|
||||
if len(highlight) > 200:
|
||||
abort(400, "Highlight name must be at most 200 characters")
|
||||
try:
|
||||
supplied_dates = (submitted_photo_dates(request.form.get("photo_dates", ""), len(photos))
|
||||
if kind == "photo" else None)
|
||||
except ValueError as error:
|
||||
abort(400, str(error))
|
||||
# Ordered byte hashes + caption make retries idempotent, even across restarts.
|
||||
digest = hashlib.sha256(caption.encode())
|
||||
if kind == "snippet":
|
||||
@@ -263,12 +331,15 @@ def create_app(data_dir=None, token=None):
|
||||
try:
|
||||
(temporary / "originals").mkdir()
|
||||
(temporary / "images").mkdir()
|
||||
capture_dates = []
|
||||
for index, photo in enumerate(photos):
|
||||
raw = photo if isinstance(photo, bytes) else photo.read()
|
||||
digest.update(hashlib.sha256(raw).digest())
|
||||
(temporary / "originals" / str(index)).write_bytes(raw)
|
||||
try:
|
||||
web_image(raw, temporary / "images" / f"{index:02}.jpg")
|
||||
embedded_date = web_image(raw, temporary / "images" / f"{index:02}.jpg")
|
||||
if kind == "photo":
|
||||
capture_dates.append(supplied_dates[index] if supplied_dates else embedded_date)
|
||||
except (UnidentifiedImageError, OSError, ValueError, SyntaxError,
|
||||
Image.DecompressionBombError, Image.DecompressionBombWarning,
|
||||
ImageCms.PyCMSError):
|
||||
@@ -276,18 +347,30 @@ def create_app(data_dir=None, token=None):
|
||||
# Preserve existing IDs when omitted; named highlights participate in deduplication.
|
||||
if highlight:
|
||||
digest.update(b"\0highlight\0" + highlight.encode())
|
||||
undated_job = digest.hexdigest()
|
||||
if supplied_dates:
|
||||
normalized_dates = "\n".join(taken.isoformat() for taken in supplied_dates)
|
||||
digest.update(b"\0photo_dates\0" + normalized_dates.encode())
|
||||
job = digest.hexdigest()
|
||||
date = datetime.now(timezone.utc).isoformat()
|
||||
manifest = {"id": job, "date": date, "caption": caption,
|
||||
"highlight": highlight, "count": len(photos)}
|
||||
if (supplied_dates and undated_job != job and
|
||||
(data / "submissions" / undated_job / "manifest.json").is_file()):
|
||||
manifest["replaces"] = undated_job
|
||||
if kind == "snippet":
|
||||
manifest.update(kind=kind, text=text)
|
||||
atomic_json(temporary / "manifest.json", manifest)
|
||||
if kind == "photo":
|
||||
if any(taken is None for taken in capture_dates):
|
||||
abort(400, "Photo capture date is missing; send photo_dates from the Photos library")
|
||||
capture_dates = distinct_photo_dates(capture_dates)
|
||||
manifest["capture_dates"] = [taken.isoformat() for taken in capture_dates]
|
||||
atomic_json(temporary / "manifest.json", manifest)
|
||||
(temporary / "entries").mkdir()
|
||||
for index in range(len(photos)):
|
||||
(temporary / "entries" / f"{index:02}.md").write_text(
|
||||
photo_entry(job, date, index, len(photos), caption, highlight))
|
||||
photo_entry(job, capture_dates[index].isoformat(), index, caption, highlight))
|
||||
# Retain the old filename for compatibility with publisher versions
|
||||
# that predate one-document-per-photo submissions.
|
||||
content = [(temporary / "entries/00.md").read_text()]
|
||||
|
||||
Reference in New Issue
Block a user