Date photo uploads from capture metadata

This commit is contained in:
2026-09-20 01:33:04 +03:30
parent afb4ed2676
commit dad62df66f
4 changed files with 227 additions and 17 deletions
+93 -10
View File
@@ -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()]