diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py new file mode 100644 index 0000000..44f9ebb --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py @@ -0,0 +1,160 @@ +""" +Index-time enrichment for the Photos group app: a resized thumbnail and a +minimal, best-effort info set (`taken_at`, `camera`) read from the image's +own EXIF block, for a newly-added image IndexEntry. + +Deliberately small — docs/photos.md §2.4 is explicit that this app does not +build a full EXIF-viewer panel. Two fields only, both best-effort (missing +EXIF is the ordinary case for a screenshot or a re-saved/edited image, not +an error). GPS is never read here, on purpose: it is a location disclosure +the instant it is surfaced to every group member, and nothing in this +module extracts, caches, or hands it to a caller. + +Runs through its own small bounded worker pool, separate from the video +(ffmpeg) and audio (mutagen) enrichment pools — mirrors enrich.py exactly, +per docs/photos.md §5's "never shared with either" rule, even though +Pillow's own work is comparatively cheap: a burst of hundreds of newly +shared photos should not peg every CPU core at once. +""" + +import asyncio +import datetime +import io +import logging +from pathlib import Path +from typing import Awaitable, Callable + +import blake3 +from PIL import ExifTags, Image, ImageOps + +from meshbay_common.protocol import IndexEntry +from meshbay_node.media_cache import MediaCache + +log = logging.getLogger(__name__) + +DEFAULT_MAX_CONCURRENT = 2 +ENRICH_TIMEOUT_SECS = 20 +THUMB_LONG_EDGE = 480 +THUMB_JPEG_QUALITY = 85 + +# Reverse-lookup: EXIF tag id -> name, built once (Image.getexif() returns a +# dict keyed by numeric tag id, not by name). +_EXIF_TAG_NAMES = {v: k for k, v in ExifTags.TAGS.items()} +# Make/Model are 0th-IFD (TIFF) tags, present directly on Image.getexif(). +_TAG_MAKE = _EXIF_TAG_NAMES.get("Make") +_TAG_MODEL = _EXIF_TAG_NAMES.get("Model") +# DateTimeOriginal is an Exif-SubIFD tag, not the 0th IFD — a real camera's +# JPEG (verified against piexif-built EXIF, matching what real hardware +# produces) never has it directly on getexif(); it is only reachable via +# getexif().get_ifd(ExifTags.IFD.Exif). Reading it off the plain top-level +# dict, as an earlier version of this module did, silently returned None +# for every real photo while Make/Model kept working — found before this +# ever ran against a real file, by testing with a properly structured EXIF +# block instead of a flat one Pillow itself is lenient enough to round-trip. +_TAG_DATETIME_ORIGINAL = _EXIF_TAG_NAMES.get("DateTimeOriginal") + + +def _parse_exif_datetime(value: str) -> int | None: + """EXIF's own format: "YYYY:MM:DD HH:MM:SS", local time, no timezone.""" + try: + dt = datetime.datetime.strptime(value.strip(), "%Y:%m:%d %H:%M:%S") + return int(dt.timestamp()) + except (ValueError, TypeError): + return None + + +def _read_image(file_path: Path) -> tuple[bytes, int, int, int | None, str | None]: + """ + Runs in a worker thread (Pillow is synchronous, and decoding a large + photo is real CPU work — same reason enrich.py's own ancestor/sibling + scans go through `asyncio.to_thread`). + + Returns (thumbnail_jpeg_bytes, width, height, taken_at, camera) for the + *original* image's own dimensions — the thumbnail is a separate, resized + copy, never what width/height describe. + """ + with Image.open(file_path) as img: + taken_at = None + camera = None + try: + exif = img.getexif() + if exif: + if _TAG_DATETIME_ORIGINAL is not None: + sub_ifd = exif.get_ifd(ExifTags.IFD.Exif) + raw = sub_ifd.get(_TAG_DATETIME_ORIGINAL) + if raw: + taken_at = _parse_exif_datetime(str(raw)) + make = exif.get(_TAG_MAKE) if _TAG_MAKE is not None else None + model = exif.get(_TAG_MODEL) if _TAG_MODEL is not None else None + make = (make or "").strip() if isinstance(make, str) else None + model = (model or "").strip() if isinstance(model, str) else None + if make or model: + camera = " ".join(p for p in (make, model) if p) + except Exception as e: + # A malformed EXIF block is a real, observed case (a corrupted + # tag, a non-standard camera) — best-effort, never fatal to the + # thumbnail itself. + log.debug("EXIF read failed for %s: %s", file_path, e) + + # Applies (and then clears) the EXIF Orientation tag before reading + # width/height and resizing — otherwise a phone photo stored + # "sideways" reports its raw, pre-rotation dimensions (swapped from + # what it actually displays as) and produces a sideways thumbnail + # (docs/photos.md §2.4). Never reads Orientation itself as a + # client-visible field; this is display correction only, and + # width/height must describe the *displayed* image, matching what + # the lightbox and the info panel show. + oriented = ImageOps.exif_transpose(img) + width, height = oriented.size + oriented.thumbnail((THUMB_LONG_EDGE, THUMB_LONG_EDGE), Image.LANCZOS) + if oriented.mode not in ("RGB", "L"): + oriented = oriented.convert("RGB") + buf = io.BytesIO() + oriented.save(buf, format="JPEG", quality=THUMB_JPEG_QUALITY) + return buf.getvalue(), width, height, taken_at, camera + + +class PhotoEnricher: + """Owns the node's bounded Photos index-time enrichment pool.""" + + def __init__(self, media_cache: MediaCache, max_concurrent: int = DEFAULT_MAX_CONCURRENT): + self._media_cache = media_cache + self._sem = asyncio.Semaphore(max_concurrent) + self._tasks: set[asyncio.Task] = set() + + def spawn( + self, entry: IndexEntry, file_path: Path, + on_done: Callable[[str, dict], Awaitable[None]], + ) -> asyncio.Task: + """Fire-and-forget, same contract as enrich.py's Enricher.spawn.""" + task = asyncio.ensure_future(self._run(entry, file_path, on_done)) + self._tasks.add(task) + + def _cleanup(t: asyncio.Task) -> None: + self._tasks.discard(t) + if not t.cancelled() and t.exception(): + log.error("Photo enrichment failed for %s: %s", entry.id[:12], t.exception(), + exc_info=t.exception()) + task.add_done_callback(_cleanup) + return task + + async def _run( + self, entry: IndexEntry, file_path: Path, + on_done: Callable[[str, dict], Awaitable[None]], + ) -> None: + async with self._sem: + fields: dict = {} + try: + thumb, width, height, taken_at, camera = await asyncio.wait_for( + asyncio.to_thread(_read_image, file_path), timeout=ENRICH_TIMEOUT_SECS) + fields["width"] = width + fields["height"] = height + fields["taken_at"] = taken_at + fields["camera"] = camera + thumb_hash = blake3.blake3(thumb).hexdigest() + await self._media_cache.put_thumb(thumb_hash, entry.id, thumb) + fields["thumb_hash"] = thumb_hash + except Exception as e: + log.warning("Photo enrichment failed for %s: %s", file_path, e) + + await on_done(entry.id, fields) |