summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/indexer/enrich_photo.py
diff options
context:
space:
mode:
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.py160
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)