From 5a67ed7e7b1fe1910094e4ce5b88d09d230b9eda Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 13:52:52 +0200 Subject: [PATCH] feat(phase 4): vision fetches NC previews; stop writing /data/thumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last consumer of the on-disk thumbnail pipeline was the vision worker reading /data/thumbs/{id}/medium.webp. Now it asks Nextcloud for a 640px preview (the same edge size the old thumb used) and decodes the bytes in-memory — no disk dependency. - nextcloud_dav.get_preview_bytes: sync sibling of get_preview_async, for the celery vision worker (which is sync). - vision._load_thumb: tries NC preview first; transitional disk fallback stays for rows still indexed during the rollout. - thumbs.WORKER_THUMB_SIZES = set() — generate_thumbnails still runs the decode + pHash side-effect (perceptual dedup is mule-only and needs original-resolution pixels) but no longer writes thumbnail files. The HTTP thumbnail endpoint's disk fallback path stays in place unchanged: for NC-404 cases (e.g. iPhone JPEGs mis-extensioned as .DNG), inline Pillow regeneration still writes a tiny per-photo file so subsequent requests are fast. That path is rare and the files are small. Disk impact: /data/thumbs currently has ~22k medium.webp totaling ~1 GB. They'll stop being read after the worker-vision container restarts, but no automatic delete — purge with the same find pattern used for small/large reclaim when ready: find /data/thumbs -name "medium.webp" -delete --- backend/app/services/nextcloud_dav.py | 29 +++++++++++ backend/app/tasks/thumbs.py | 14 +++--- backend/app/tasks/vision.py | 71 ++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/backend/app/services/nextcloud_dav.py b/backend/app/services/nextcloud_dav.py index e978f91..143d64f 100644 --- a/backend/app/services/nextcloud_dav.py +++ b/backend/app/services/nextcloud_dav.py @@ -344,6 +344,35 @@ def fetch_fileid(user: User, abs_path: str) -> Optional[int]: return int(m.group(1)) if m else None +def get_preview_bytes( + user: User, fileid: int, x: int, y: int +) -> Optional[bytes]: + """Sync sibling of `get_preview_async` for callers in non-async + contexts (e.g. the vision celery worker, which is sync). + + Returns the preview body on success, None on 404 / non-success / + missing credentials. Caller is expected to feed the bytes into + PIL or similar. + """ + try: + nc_user, app_pw = _credentials_for(user) + except NextcloudCredentialsMissing: + return None + url = f"{_base_url()}/index.php/core/preview" + params = { + "fileId": str(fileid), + "x": str(x), + "y": str(y), + "a": "true", + "forceIcon": "false", + } + with _client((nc_user, app_pw)) as c: + resp = c.get(url, params=params) + if resp.status_code == 404 or not resp.is_success: + return None + return resp.content + + async def fetch_memories_info_async( user: User, fileid: int ) -> Optional[dict]: diff --git a/backend/app/tasks/thumbs.py b/backend/app/tasks/thumbs.py index 8111f37..83eb9ea 100644 --- a/backend/app/tasks/thumbs.py +++ b/backend/app/tasks/thumbs.py @@ -47,13 +47,13 @@ THUMB_SIZES = { 'large': settings.thumbnails.large } -# Sizes the worker actually writes to /data/thumbs. We used to write all -# three, but the API now proxies Nextcloud's /core/preview for `small` -# and `large` — only `medium` survives on disk because the vision -# pipeline (app.tasks.vision) still loads it from there. When vision -# moves to NC previews too, this set drops to empty and the file -# pipeline can be deleted entirely. -WORKER_THUMB_SIZES = {'medium'} +# Sizes the worker writes to /data/thumbs. Empty set since Phase 4 — +# the API serves all sizes via Nextcloud's /core/preview proxy, and +# the vision worker also fetches NC previews on demand instead of +# reading a local cache. generate_thumbnails still runs the decode- +# and-pHash side-effect (perceptual dedup is mule-only and needs the +# original-resolution pixels) but no longer touches the disk. +WORKER_THUMB_SIZES: set[str] = set() def get_thumb_path(photo_id: str, size: str, user_id: str = None) -> str: """Get the path for a thumbnail file. diff --git a/backend/app/tasks/vision.py b/backend/app/tasks/vision.py index 7976842..9796930 100644 --- a/backend/app/tasks/vision.py +++ b/backend/app/tasks/vision.py @@ -45,7 +45,76 @@ def _get_sync_session() -> Session: return sessionmaker(bind=_get_sync_engine())() +# CLIP was trained against 640px medium thumbs that the on-disk +# pipeline used to produce. Now we ask Nextcloud's preview endpoint +# for the same edge size so the classifier sees the same input +# distribution. +_VISION_PREVIEW_PX = 640 + + def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None: + """Load the photo's RGB pixels into a numpy array for inference. + + Primary path: ask Nextcloud's `/index.php/core/preview` for a + 640px preview via the sync helper. Replaces the disk read of + `/data/thumbs/{photo_id}/medium.webp` so the on-disk pipeline + can retire entirely. + + Disk fallback (transitional): if NC has no preview or no + credentials, look for the medium.webp the old pipeline wrote. + Goes dead once `generate_thumbnails` stops writing files. + """ + from io import BytesIO + from app.models import Photo + from app.models.user import User + from app.services.nextcloud_dav import get_preview_bytes + + user_id: str | None = None + fileid: int | None = None + session = _get_sync_session() + try: + row = session.execute( + select(Photo.user_id, Photo.nextcloud_fileid).where( + Photo.id == photo_id + ) + ).one_or_none() + if row: + user_id, fileid = row[0], row[1] + finally: + session.close() + + if user_id and fileid: + session = _get_sync_session() + try: + owner = session.execute( + select(User).where(User.id == user_id) + ).scalar_one_or_none() + finally: + session.close() + if owner is not None and owner.nextcloud_app_password_enc: + try: + body = get_preview_bytes( + owner, fileid, _VISION_PREVIEW_PX, _VISION_PREVIEW_PX, + ) + except Exception as e: + logger.warning( + "NC preview fetch failed for %s: %s", photo_id, e + ) + body = None + if body: + try: + img = Image.open(BytesIO(body)).convert("RGB") + img.load() + arr = np.array(img) + img.close() + return arr + except Exception as e: + logger.warning( + "NC preview decode failed for %s: %s", photo_id, e + ) + + # Legacy disk fallback — transitional, dead once thumbs.py stops + # writing /data/thumbs. thumb_base = Path("/data/thumbs") thumb_path = thumb_base / photo_id / f"{size}.webp" if not thumb_path.exists(): @@ -53,7 +122,7 @@ def _load_thumb(photo_id: str, size: str = "medium") -> np.ndarray | None: if matches: thumb_path = matches[0] else: - logger.warning("Thumbnail not found: %s", thumb_path) + logger.warning("Thumbnail not found anywhere for %s", photo_id) return None try: img = Image.open(thumb_path).convert("RGB")