From c787c619e66ead7390a3e551f57bb410c8fdb2e8 Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Mon, 13 Jul 2026 02:09:54 +0530
Subject: [PATCH 01/21] feat: add accounts maps vault and runtime controls
---
.../alembic/versions/20260712_media_gps.py | 28 ++
backend/pyproject.toml | 55 ++-
backend/src/find_api/__init__.py | 2 +-
backend/src/find_api/core/config.py | 34 +-
backend/src/find_api/core/database.py | 24 +
backend/src/find_api/core/dependencies.py | 27 +-
backend/src/find_api/core/hardware.py | 6 +-
backend/src/find_api/core/model_manager.py | 13 +-
backend/src/find_api/core/runtime_profile.py | 325 +++++++++++++
backend/src/find_api/main.py | 7 +-
backend/src/find_api/ml/captioner.py | 5 +-
backend/src/find_api/ml/clip_embedder.py | 3 +-
backend/src/find_api/ml/clusterer.py | 7 +-
backend/src/find_api/ml/face_detector.py | 6 +-
backend/src/find_api/ml/object_detector.py | 3 +-
backend/src/find_api/models/app_setting.py | 11 +-
backend/src/find_api/models/media.py | 4 +
backend/src/find_api/routers/auth.py | 209 ++++++++-
backend/src/find_api/routers/config.py | 108 ++++-
backend/src/find_api/routers/gallery.py | 22 +
backend/src/find_api/routers/map.py | 111 +++++
backend/src/find_api/routers/search.py | 55 ++-
backend/src/find_api/routers/status.py | 4 +
backend/src/find_api/routers/vault.py | 325 ++++++++++++-
backend/src/find_api/utils/exif.py | 41 +-
backend/src/find_api/workers/jobs.py | 165 +++++--
backend/src/find_api/workers/processors.py | 16 +-
backend/tests/test_auth.py | 88 ++++
.../tests/test_cluster_images_regression.py | 4 +
backend/tests/test_clustering_edge_cases.py | 12 +
backend/tests/test_exif.py | 72 +++
backend/tests/test_gallery.py | 18 +
backend/tests/test_hybrid_embedding.py | 3 +-
backend/tests/test_map.py | 160 +++++++
backend/tests/test_model_manager.py | 1 +
backend/tests/test_runtime_profile.py | 83 ++++
backend/tests/test_search.py | 23 +-
backend/tests/test_settings.py | 60 +++
backend/tests/test_status.py | 3 +
backend/tests/test_thumbnails.py | 47 ++
backend/tests/test_vault.py | 226 ++++++++-
backend/uv.lock | 434 +++++++++++-------
42 files changed, 2547 insertions(+), 303 deletions(-)
create mode 100644 backend/alembic/versions/20260712_media_gps.py
create mode 100644 backend/src/find_api/core/runtime_profile.py
create mode 100644 backend/src/find_api/routers/map.py
create mode 100644 backend/tests/test_exif.py
create mode 100644 backend/tests/test_map.py
create mode 100644 backend/tests/test_runtime_profile.py
diff --git a/backend/alembic/versions/20260712_media_gps.py b/backend/alembic/versions/20260712_media_gps.py
new file mode 100644
index 00000000..027db491
--- /dev/null
+++ b/backend/alembic/versions/20260712_media_gps.py
@@ -0,0 +1,28 @@
+"""Add opt-in GPS coordinates for the private map.
+
+Revision ID: 20260712mediagps
+Revises: 20260630partnershares
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "20260712mediagps"
+down_revision = "20260630partnershares"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column("media", sa.Column("latitude", sa.Float(), nullable=True))
+ op.add_column("media", sa.Column("longitude", sa.Float(), nullable=True))
+ op.create_index("ix_media_latitude", "media", ["latitude"])
+ op.create_index("ix_media_longitude", "media", ["longitude"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_media_longitude", table_name="media")
+ op.drop_index("ix_media_latitude", table_name="media")
+ op.drop_column("media", "longitude")
+ op.drop_column("media", "latitude")
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index c802e66f..0575ffb2 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "find-backend"
-version = "1.0.0"
+version = "1.1.0"
description = "FastAPI backend and RQ worker for Find"
license = "AGPL-3.0-only"
requires-python = ">=3.12,<3.13"
@@ -18,12 +18,8 @@ dependencies = [
"redis==5.0.1",
"rq>=2.6.1,<3",
"pillow>=12.2.0",
- "scikit-learn==1.5.0",
"numpy==1.26.3",
- "scipy==1.12.0",
- "cython<3",
"piexif==1.1.3",
- "imagehash==4.3.1",
"cryptography>=48.0.1,<49",
"passlib[bcrypt]==1.7.4",
"argon2-cffi>=23.1.0",
@@ -34,7 +30,26 @@ dependencies = [
]
[project.optional-dependencies]
-ml = [
+mock = [
+ "scikit-learn==1.5.0",
+]
+cpu = [
+ "torch==2.8.0",
+ "torchvision==0.23.0",
+ "transformers>=5.3.0,<6",
+ "open-clip-torch>=2.24.0",
+ "opencv-python-headless==4.9.0.80",
+ "ultralytics>=8.2.0",
+ "insightface==0.7.3",
+ "onnxruntime==1.17.0",
+ "timm>=0.9.16",
+ "einops>=0.8.0",
+ "paddlepaddle>=3.2.2,<3.3",
+ "paddleocr>=3.5.0,<4",
+ "scikit-learn==1.5.0",
+ "cython<3",
+]
+nvidia = [
"torch==2.8.0",
"torchvision==0.23.0",
"transformers>=5.3.0,<6",
@@ -47,8 +62,8 @@ ml = [
"einops>=0.8.0",
"paddlepaddle>=3.2.2,<3.3",
"paddleocr>=3.5.0,<4",
- "hdbscan==0.8.40",
- "faiss-cpu>=1.13.2,<1.14",
+ "scikit-learn==1.5.0",
+ "cython<3",
]
[build-system]
@@ -65,6 +80,7 @@ dev = [
"black==26.3.1",
"ruff==0.1.14",
"pip-audit==2.9.0",
+ "scikit-learn==1.5.0",
]
[tool.pytest.ini_options]
@@ -75,8 +91,27 @@ markers = [
]
[tool.uv.sources]
-torch = { index = "pytorch-cu128" }
-torchvision = { index = "pytorch-cu128" }
+torch = [
+ { index = "pytorch-cpu", extra = "cpu" },
+ { index = "pytorch-cu128", extra = "nvidia" },
+]
+torchvision = [
+ { index = "pytorch-cpu", extra = "cpu" },
+ { index = "pytorch-cu128", extra = "nvidia" },
+]
+
+[tool.uv]
+conflicts = [
+ [
+ { extra = "cpu" },
+ { extra = "nvidia" },
+ ],
+]
+
+[[tool.uv.index]]
+name = "pytorch-cpu"
+url = "https://download.pytorch.org/whl/cpu"
+explicit = true
[[tool.uv.index]]
name = "pytorch-cu128"
diff --git a/backend/src/find_api/__init__.py b/backend/src/find_api/__init__.py
index 0b4c862d..bf444cdb 100644
--- a/backend/src/find_api/__init__.py
+++ b/backend/src/find_api/__init__.py
@@ -1,3 +1,3 @@
"""Find backend application"""
-__version__ = "1.0.0"
+__version__ = "1.1.0"
diff --git a/backend/src/find_api/core/config.py b/backend/src/find_api/core/config.py
index b0805e35..9d17d355 100644
--- a/backend/src/find_api/core/config.py
+++ b/backend/src/find_api/core/config.py
@@ -44,8 +44,21 @@ class Settings(BaseSettings):
"queue.db",
)
+ # Runtime/build profile. Docker images set this explicitly so the API can
+ # distinguish installed capabilities from hardware that merely exists on
+ # the host. ``development`` keeps source checkouts backwards compatible.
+ FIND_BUILD_PROFILE: Literal[
+ "development", "no-ai", "mock", "cpu", "nvidia"
+ ] = "development"
+
# ML Models
- ML_MODE: Literal["full", "mock", "remote"] = "full"
+ ML_MODE: Literal["disabled", "full", "mock", "remote"] = "full"
+ # Instance-wide kill switch. The persisted dashboard preference overrides
+ # this value at job boundaries; it never installs a missing runtime.
+ AI_ENABLED: bool = True
+ # GPS extraction is privacy-sensitive and therefore opt-in. The persisted
+ # dashboard preference overrides this value at job boundaries.
+ MAP_ENABLED: bool = False
REMOTE_ML_URL: Optional[str] = None
REMOTE_ML_API_KEY: Optional[str] = None
REMOTE_ML_STRIP_EXIF: bool = True
@@ -128,24 +141,13 @@ def validate_image_pixel_ceiling(cls, value: int):
@model_validator(mode="after")
def validate_remote_ml_config(self):
- """Require remote ML settings when remote mode is enabled."""
- if self.ML_MODE.lower() != "remote":
+ """Require explicit self-hosted endpoint credentials for remote mode."""
+ if self.ML_MODE != "remote":
return self
-
if not self.REMOTE_ML_URL or not self.REMOTE_ML_URL.strip():
- raise ValueError(
- "ML_MODE=remote requires REMOTE_ML_URL. "
- "Set REMOTE_ML_URL to a reachable self-hosted Find ML server "
- "or change ML_MODE to full or mock."
- )
-
+ raise ValueError("ML_MODE=remote requires REMOTE_ML_URL")
if not self.REMOTE_ML_API_KEY or not self.REMOTE_ML_API_KEY.strip():
- raise ValueError(
- "ML_MODE=remote requires REMOTE_ML_API_KEY. "
- "Set REMOTE_ML_API_KEY to a bearer token shared with your remote ML server "
- "or change ML_MODE to full or mock."
- )
-
+ raise ValueError("ML_MODE=remote requires REMOTE_ML_API_KEY")
return self
@model_validator(mode="after")
diff --git a/backend/src/find_api/core/database.py b/backend/src/find_api/core/database.py
index 539b8c45..4da41320 100644
--- a/backend/src/find_api/core/database.py
+++ b/backend/src/find_api/core/database.py
@@ -144,6 +144,18 @@ def init_db():
"ADD COLUMN IF NOT EXISTS thumbnail_height INTEGER"
)
)
+ conn.execute(
+ text(
+ "ALTER TABLE IF EXISTS media "
+ "ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION"
+ )
+ )
+ conn.execute(
+ text(
+ "ALTER TABLE IF EXISTS media "
+ "ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION"
+ )
+ )
conn.execute(
text(
"ALTER TABLE IF EXISTS media "
@@ -246,6 +258,18 @@ def init_db():
"ON media (deleted_at)"
)
)
+ conn.execute(
+ text(
+ "CREATE INDEX IF NOT EXISTS ix_media_latitude "
+ "ON media (latitude)"
+ )
+ )
+ conn.execute(
+ text(
+ "CREATE INDEX IF NOT EXISTS ix_media_longitude "
+ "ON media (longitude)"
+ )
+ )
conn.execute(
text(
"CREATE TABLE IF NOT EXISTS vault_config ("
diff --git a/backend/src/find_api/core/dependencies.py b/backend/src/find_api/core/dependencies.py
index 93e73528..e827548e 100644
--- a/backend/src/find_api/core/dependencies.py
+++ b/backend/src/find_api/core/dependencies.py
@@ -9,7 +9,7 @@
from typing import Optional, TypeVar
-from fastapi import Depends, Header, HTTPException
+from fastapi import Cookie, Depends, Header, HTTPException
from sqlalchemy.orm import Query, Session
from find_api.core.auth import get_current_user, is_shared_mode
@@ -18,10 +18,23 @@
from find_api.models.user import User
Q = TypeVar("Q", bound=Query)
+SESSION_COOKIE_NAME = "find_session"
+
+
+def _session_authorization(
+ authorization: Optional[str], session_cookie: Optional[str]
+) -> Optional[str]:
+ """Prefer an explicit bearer token, otherwise use the HttpOnly session cookie."""
+ if authorization:
+ return authorization
+ if session_cookie:
+ return f"Bearer {session_cookie}"
+ return None
def get_optional_user(
authorization: Optional[str] = Header(None),
+ session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
db: Session = Depends(get_db),
) -> Optional[User]:
"""Return the authenticated user if present, None otherwise.
@@ -29,11 +42,12 @@ def get_optional_user(
Never raises — use this for endpoints that work in both local
and shared mode (e.g. upload records the uploader when known).
"""
- return get_current_user(db, authorization)
+ return get_current_user(db, _session_authorization(authorization, session_cookie))
def get_required_user(
authorization: Optional[str] = Header(None),
+ session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
db: Session = Depends(get_db),
) -> Optional[User]:
"""Return the authenticated user or raise 401 in shared mode.
@@ -41,7 +55,7 @@ def get_required_user(
In local mode (no admin exists) this returns None silently,
preserving the existing unauthenticated behavior.
"""
- user = get_current_user(db, authorization)
+ user = get_current_user(db, _session_authorization(authorization, session_cookie))
if user is not None:
return user
@@ -54,6 +68,7 @@ def get_required_user(
def get_admin_user(
authorization: Optional[str] = Header(None),
+ session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
db: Session = Depends(get_db),
) -> Optional[User]:
"""Return the authenticated user only if they have admin role.
@@ -62,7 +77,11 @@ def get_admin_user(
Raises 403 if authenticated but not an admin.
In local mode, returns None.
"""
- user = get_required_user(authorization=authorization, db=db)
+ user = get_required_user(
+ authorization=authorization,
+ session_cookie=session_cookie,
+ db=db,
+ )
if user is None:
return None # local mode
diff --git a/backend/src/find_api/core/hardware.py b/backend/src/find_api/core/hardware.py
index 975eb363..4dbb4691 100644
--- a/backend/src/find_api/core/hardware.py
+++ b/backend/src/find_api/core/hardware.py
@@ -174,10 +174,12 @@ def current_torch_device() -> str:
Live helper used by the ML modules. Never raises — degrades to "cpu".
Honors the legacy ``USE_GPU=false`` as a hard CPU pin for back-compat.
"""
- # Imported lazily to avoid a hard dependency at module import.
+ # Imported lazily to avoid a hard dependency at module import. A worker
+ # job may bind a persisted dashboard preference for its lifetime.
from find_api.core.config import settings
+ from find_api.core.runtime_profile import current_accel_mode
- mode: AccelMode = getattr(settings, "ACCEL_MODE", "auto")
+ mode = current_accel_mode()
# Back-compat: an explicit USE_GPU=False forces CPU regardless of mode.
if getattr(settings, "USE_GPU", True) is False and mode == "auto":
return "cpu"
diff --git a/backend/src/find_api/core/model_manager.py b/backend/src/find_api/core/model_manager.py
index 02a81f0d..6c49f002 100644
--- a/backend/src/find_api/core/model_manager.py
+++ b/backend/src/find_api/core/model_manager.py
@@ -80,6 +80,7 @@ def __init__(self):
self._loading: Dict[str, threading.Event] = {}
self.failed_loads: Dict[str, Dict[str, Any]] = {}
self.unavailable_models: Dict[str, ModelLoadFailure] = {}
+ self.runtime_status: Dict[str, Any] | None = None
self._lock = threading.RLock()
self.gpu_lock = asyncio.Lock()
self._cleanup_thread = None
@@ -304,13 +305,20 @@ def reset_for_tests(self):
self._loading.clear()
self.failed_loads.clear()
self.unavailable_models.clear()
+ self.runtime_status = None
self.max_loaded_models = settings.ML_MAX_LOADED_MODELS
self.publish_status()
+ def set_runtime_status(self, status: Dict[str, Any]) -> None:
+ """Publish the runtime snapshot most recently applied by this process."""
+ with self._lock:
+ self.runtime_status = status.copy()
+ self.publish_status()
+
def get_status(self) -> Dict[str, Any]:
"""Return current process model-manager status."""
with self._lock:
- return {
+ status = {
"process": self.process_name,
"loaded_models": list(self.models.keys()),
"in_flight": {
@@ -320,6 +328,9 @@ def get_status(self) -> Dict[str, Any]:
"max_loaded_models": self.max_loaded_models,
"updated_at": time.time(),
}
+ if self.runtime_status is not None:
+ status["runtime"] = self.runtime_status.copy()
+ return status
def publish_status(self):
"""Best-effort publish of this process model state for API observability."""
diff --git a/backend/src/find_api/core/runtime_profile.py b/backend/src/find_api/core/runtime_profile.py
new file mode 100644
index 00000000..4904090b
--- /dev/null
+++ b/backend/src/find_api/core/runtime_profile.py
@@ -0,0 +1,325 @@
+"""Truthful runtime/build capability resolution.
+
+The deployment artifact (no-AI, mock, CPU, or NVIDIA) determines which
+dependencies are installed. Runtime settings may disable an installed feature,
+but they must never claim to install or activate a capability that is absent.
+Worker jobs bind the resolved preferences through context variables so cached
+model loaders use the dashboard-selected acceleration mode without mutating the
+process-wide Pydantic settings object.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import time
+from contextlib import contextmanager
+from contextvars import ContextVar, Token
+from dataclasses import asdict, dataclass
+from typing import Any, Literal
+
+from sqlalchemy.orm import Session
+
+from find_api.core.config import settings
+from find_api.models.app_setting import AppSetting
+
+AccelMode = Literal["auto", "gpu", "cpu"]
+ConfiguredMLMode = Literal["disabled", "full", "mock", "remote"]
+AppliedMLMode = Literal["disabled", "full", "mock", "unavailable"]
+
+ACCEL_MODE_KEY = "accel_mode"
+AI_ENABLED_KEY = "ai_enabled"
+MAP_ENABLED_KEY = "map_enabled"
+ML_MODE_KEY = "ml_mode"
+RUNTIME_SETTING_KEYS = (ACCEL_MODE_KEY, AI_ENABLED_KEY, MAP_ENABLED_KEY, ML_MODE_KEY)
+
+CORE_FEATURES = ("thumbnails", "dimensions", "exif")
+MOCK_FEATURES = ("mock_inference", "semantic_search", "clustering")
+FULL_FEATURES = (
+ "embeddings",
+ "captioning",
+ "object_detection",
+ "ocr",
+ "face_detection",
+ "semantic_search",
+ "clustering",
+)
+
+_PROFILE_MODES: dict[str, tuple[str, ...]] = {
+ "no-ai": ("disabled",),
+ "mock": ("disabled", "mock"),
+ "cpu": ("disabled", "mock", "full"),
+ "nvidia": ("disabled", "mock", "full"),
+}
+
+_active_ml_mode: ContextVar[str | None] = ContextVar(
+ "find_active_ml_mode", default=None
+)
+_active_accel_mode: ContextVar[str | None] = ContextVar(
+ "find_active_accel_mode", default=None
+)
+_active_map_enabled: ContextVar[bool | None] = ContextVar(
+ "find_active_map_enabled", default=None
+)
+
+
+class RuntimeUnavailableError(RuntimeError):
+ """Raised when requested inference is not present in this artifact."""
+
+ def __init__(self, resolution: "RuntimeResolution"):
+ self.resolution = resolution
+ super().__init__(
+ resolution.unavailable_reason
+ or "The configured AI runtime is unavailable in this artifact."
+ )
+
+
+@dataclass(frozen=True)
+class RuntimePreferences:
+ """Persisted instance preferences resolved with environment fallbacks."""
+
+ accel_mode: AccelMode
+ ai_enabled: bool
+ map_enabled: bool
+ ml_mode: ConfiguredMLMode
+
+
+@dataclass(frozen=True)
+class RuntimeResolution:
+ """Desired runtime mapped onto the capabilities installed in this image."""
+
+ build_profile: str
+ supported_modes: tuple[str, ...]
+ configured_mode: ConfiguredMLMode
+ configured_accel_mode: AccelMode
+ ai_enabled: bool
+ map_enabled: bool
+ applied_mode: AppliedMLMode
+ installed_features: tuple[str, ...]
+ restart_required: bool
+ unavailable_reason: str | None = None
+
+ def to_worker_status(self, *, source: str) -> dict[str, Any]:
+ payload = asdict(self)
+ payload["supported_modes"] = list(self.supported_modes)
+ payload["installed_features"] = list(self.installed_features)
+ payload["preferences_source"] = source
+ return payload
+
+
+def _package_available(module_name: str) -> bool:
+ try:
+ return importlib.util.find_spec(module_name) is not None
+ except (ImportError, ValueError):
+ return False
+
+
+def _development_modes() -> tuple[str, ...]:
+ modes = ["disabled"]
+ if _package_available("numpy") and _package_available("sklearn"):
+ modes.append("mock")
+ full_modules = (
+ "torch",
+ "open_clip",
+ "transformers",
+ "ultralytics",
+ "paddleocr",
+ "insightface",
+ "onnxruntime",
+ )
+ if all(_package_available(module) for module in full_modules):
+ modes.append("full")
+ return tuple(modes)
+
+
+def supported_modes(build_profile: str | None = None) -> tuple[str, ...]:
+ profile = build_profile or settings.FIND_BUILD_PROFILE
+ if profile == "development":
+ return _development_modes()
+ return _PROFILE_MODES.get(profile, ("disabled",))
+
+
+def installed_features(build_profile: str | None = None) -> tuple[str, ...]:
+ profile = build_profile or settings.FIND_BUILD_PROFILE
+ modes = supported_modes(profile)
+ features = list(CORE_FEATURES)
+ if "mock" in modes:
+ features.extend(MOCK_FEATURES)
+ if "full" in modes:
+ features.extend(FULL_FEATURES)
+ return tuple(dict.fromkeys(features))
+
+
+def _parse_bool(value: str | None, default: bool) -> bool:
+ if value is None:
+ return default
+ normalized = value.strip().lower()
+ if normalized in {"1", "true", "yes", "on"}:
+ return True
+ if normalized in {"0", "false", "no", "off"}:
+ return False
+ return default
+
+
+def default_runtime_preferences() -> RuntimePreferences:
+ return RuntimePreferences(
+ accel_mode=settings.ACCEL_MODE,
+ ai_enabled=settings.AI_ENABLED,
+ map_enabled=settings.MAP_ENABLED,
+ ml_mode=settings.ML_MODE,
+ )
+
+
+def load_runtime_preferences(db: Session) -> RuntimePreferences:
+ """Read all runtime preferences once at an API request/job boundary."""
+ rows = db.query(AppSetting).filter(AppSetting.key.in_(RUNTIME_SETTING_KEYS)).all()
+ values = {row.key: row.value for row in rows}
+ accel_mode = values.get(ACCEL_MODE_KEY, settings.ACCEL_MODE)
+ if accel_mode not in {"auto", "gpu", "cpu"}:
+ accel_mode = settings.ACCEL_MODE
+ ml_mode = values.get(ML_MODE_KEY, settings.ML_MODE)
+ if ml_mode not in {"disabled", "full", "mock", "remote"}:
+ ml_mode = settings.ML_MODE
+ return RuntimePreferences(
+ accel_mode=accel_mode, # type: ignore[arg-type]
+ ai_enabled=_parse_bool(values.get(AI_ENABLED_KEY), settings.AI_ENABLED),
+ map_enabled=_parse_bool(values.get(MAP_ENABLED_KEY), settings.MAP_ENABLED),
+ ml_mode=ml_mode, # type: ignore[arg-type]
+ )
+
+
+def resolve_runtime(
+ preferences: RuntimePreferences | None = None,
+ *,
+ build_profile: str | None = None,
+ configured_mode: ConfiguredMLMode | None = None,
+) -> RuntimeResolution:
+ """Resolve preferences without ever falling into an unavailable backend."""
+ preferences = preferences or default_runtime_preferences()
+ profile = build_profile or settings.FIND_BUILD_PROFILE
+ mode = configured_mode or preferences.ml_mode
+ modes = supported_modes(profile)
+ features = installed_features(profile)
+
+ if not preferences.ai_enabled or mode == "disabled":
+ applied_mode: AppliedMLMode = "disabled"
+ reason = None
+ restart_required = False
+ elif mode == "remote":
+ applied_mode = "unavailable"
+ reason = (
+ "Remote ML is configured but no remote inference client is installed. "
+ "Find will not fall back to local inference."
+ )
+ restart_required = False
+ elif mode not in modes:
+ applied_mode = "unavailable"
+ reason = (
+ f"ML mode '{mode}' is not installed in the '{profile}' artifact. "
+ "Start Find with a compatible CPU or NVIDIA profile."
+ )
+ restart_required = True
+ else:
+ applied_mode = mode # type: ignore[assignment]
+ reason = None
+ restart_required = False
+
+ return RuntimeResolution(
+ build_profile=profile,
+ supported_modes=modes,
+ configured_mode=mode,
+ configured_accel_mode=preferences.accel_mode,
+ ai_enabled=preferences.ai_enabled,
+ map_enabled=preferences.map_enabled,
+ applied_mode=applied_mode,
+ installed_features=features,
+ restart_required=restart_required,
+ unavailable_reason=reason,
+ )
+
+
+def bind_runtime(
+ resolution: RuntimeResolution,
+) -> tuple[Token[str | None], Token[str | None], Token[bool | None]]:
+ """Bind a job/request runtime until :func:`reset_runtime` is called."""
+ return (
+ _active_ml_mode.set(resolution.applied_mode),
+ _active_accel_mode.set(resolution.configured_accel_mode),
+ _active_map_enabled.set(resolution.map_enabled),
+ )
+
+
+def reset_runtime(
+ tokens: tuple[Token[str | None], Token[str | None], Token[bool | None]],
+) -> None:
+ _active_ml_mode.reset(tokens[0])
+ _active_accel_mode.reset(tokens[1])
+ _active_map_enabled.reset(tokens[2])
+
+
+@contextmanager
+def runtime_context(db: Session, *, source: str = "database"):
+ """Resolve, bind, and publish one consistent job/request runtime snapshot."""
+ resolution = resolve_runtime(load_runtime_preferences(db))
+ tokens = bind_runtime(resolution)
+ try:
+ if source == "worker":
+ from find_api.core.model_manager import get_model_manager
+
+ get_model_manager().set_runtime_status(
+ resolution.to_worker_status(source="database")
+ )
+ yield resolution
+ finally:
+ reset_runtime(tokens)
+
+
+def current_ml_mode() -> str:
+ return _active_ml_mode.get() or resolve_runtime().applied_mode
+
+
+def current_accel_mode() -> AccelMode:
+ mode = _active_accel_mode.get() or settings.ACCEL_MODE
+ return mode if mode in {"auto", "gpu", "cpu"} else "auto" # type: ignore[return-value]
+
+
+def current_map_enabled() -> bool:
+ value = _active_map_enabled.get()
+ return settings.MAP_ENABLED if value is None else value
+
+
+def get_worker_process_status() -> dict[str, Any] | None:
+ """Read the worker heartbeat published by ``ModelManager`` via Redis."""
+ try:
+ from find_api.core.queue import get_redis_connection
+
+ raw = get_redis_connection().get("find:model_status:worker")
+ if not raw:
+ return None
+ if isinstance(raw, bytes):
+ raw = raw.decode("utf-8")
+ payload = json.loads(raw)
+ return payload if isinstance(payload, dict) else None
+ except Exception:
+ return None
+
+
+def get_worker_runtime_status() -> dict[str, Any] | None:
+ status = get_worker_process_status()
+ if not status:
+ return None
+ runtime = status.get("runtime")
+ return runtime if isinstance(runtime, dict) else None
+
+
+def worker_health(status: dict[str, Any] | None) -> dict[str, Any]:
+ if status is None:
+ return {"state": "unavailable", "age_seconds": None}
+ updated_at = status.get("updated_at")
+ if not isinstance(updated_at, (int, float)):
+ return {"state": "unknown", "age_seconds": None}
+ age = max(0.0, time.time() - float(updated_at))
+ return {
+ "state": "healthy" if age <= 150 else "stale",
+ "age_seconds": round(age, 1),
+ }
diff --git a/backend/src/find_api/main.py b/backend/src/find_api/main.py
index ef623521..9c3330f0 100644
--- a/backend/src/find_api/main.py
+++ b/backend/src/find_api/main.py
@@ -12,6 +12,7 @@
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from fastapi.staticfiles import StaticFiles
+from find_api import __version__
from find_api.routers.duplicates import router as duplicates_router
from find_api.core.database import init_db
from find_api.core.recovery import run_analysis_recovery_loop
@@ -25,6 +26,7 @@
config,
feedback,
gallery,
+ map,
people,
search,
status,
@@ -101,7 +103,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="Find API",
description="Local-first AI image intelligence platform",
- version="1.0.0",
+ version=__version__,
lifespan=lifespan,
)
app.state.limiter = limiter
@@ -135,6 +137,7 @@ async def lifespan(app: FastAPI):
app.include_router(upload.router, prefix="/api", tags=["upload"])
app.include_router(gallery.router, prefix="/api", tags=["gallery"])
app.include_router(timeline.router, prefix="/api", tags=["timeline"])
+app.include_router(map.router, prefix="/api", tags=["map"])
app.include_router(album.router, prefix="/api", tags=["albums"])
app.include_router(shared_link.router, prefix="/api", tags=["shared-links"])
app.include_router(partner.router, prefix="/api", tags=["partners"])
@@ -154,7 +157,7 @@ async def root():
"""Root endpoint"""
return {
"message": "Find API - Local-first AI image intelligence",
- "version": "1.0.0",
+ "version": __version__,
"status": "operational",
}
diff --git a/backend/src/find_api/ml/captioner.py b/backend/src/find_api/ml/captioner.py
index 00d0abeb..96179bed 100644
--- a/backend/src/find_api/ml/captioner.py
+++ b/backend/src/find_api/ml/captioner.py
@@ -12,6 +12,7 @@
from find_api.core.config import settings
from find_api.core.hardware import current_torch_device
from find_api.core.model_manager import get_model_manager
+from find_api.core.runtime_profile import current_accel_mode
logger = logging.getLogger(__name__)
@@ -63,7 +64,7 @@ def generate_caption(
if image.mode != "RGB":
image = image.convert("RGB")
- config_key = f"model={settings.BLIP_MODEL}|accel={settings.ACCEL_MODE}"
+ config_key = f"model={settings.BLIP_MODEL}|accel={current_accel_mode()}"
with self.manager.use_model(
"florence-2", self._load_model, config_key=config_key
) as bundle:
@@ -127,7 +128,7 @@ def generate_conditional_caption(
if image.mode != "RGB":
image = image.convert("RGB")
- config_key = f"model={settings.BLIP_MODEL}|accel={settings.ACCEL_MODE}"
+ config_key = f"model={settings.BLIP_MODEL}|accel={current_accel_mode()}"
with self.manager.use_model(
"florence-2", self._load_model, config_key=config_key
) as bundle:
diff --git a/backend/src/find_api/ml/clip_embedder.py b/backend/src/find_api/ml/clip_embedder.py
index 492741ea..b579c2f2 100644
--- a/backend/src/find_api/ml/clip_embedder.py
+++ b/backend/src/find_api/ml/clip_embedder.py
@@ -12,6 +12,7 @@
from find_api.core.config import settings
from find_api.core.hardware import current_torch_device
from find_api.core.model_manager import get_model_manager
+from find_api.core.runtime_profile import current_accel_mode
logger = logging.getLogger(__name__)
@@ -48,7 +49,7 @@ def _load_model(self):
def _config_key(self) -> str:
return (
f"model={settings.CLIP_MODEL}|pretrained={settings.CLIP_PRETRAINED}|"
- f"accel={settings.ACCEL_MODE}"
+ f"accel={current_accel_mode()}"
)
def embed_image(self, image: Union[Image.Image, np.ndarray]) -> np.ndarray:
diff --git a/backend/src/find_api/ml/clusterer.py b/backend/src/find_api/ml/clusterer.py
index e6319d1c..685b4e3d 100644
--- a/backend/src/find_api/ml/clusterer.py
+++ b/backend/src/find_api/ml/clusterer.py
@@ -8,6 +8,7 @@
import logging
from find_api.core.config import settings
+from find_api.core.runtime_profile import current_accel_mode
logger = logging.getLogger(__name__)
@@ -89,7 +90,11 @@ def cluster(
def _fit_predict(self, embeddings: np.ndarray, metric: str) -> np.ndarray:
backend = settings.CLUSTERING_BACKEND.lower()
- if settings.USE_GPU and backend in {"auto", "cuml"}:
+ if (
+ settings.USE_GPU
+ and current_accel_mode() != "cpu"
+ and backend in {"auto", "cuml"}
+ ):
try:
labels = self._fit_predict_cuml(embeddings, metric)
logger.info("Clustering used cuML GPU backend")
diff --git a/backend/src/find_api/ml/face_detector.py b/backend/src/find_api/ml/face_detector.py
index a27b338c..aadf3413 100644
--- a/backend/src/find_api/ml/face_detector.py
+++ b/backend/src/find_api/ml/face_detector.py
@@ -6,9 +6,9 @@
from PIL import Image
from typing import Dict, List, Union
-from find_api.core.config import settings
from find_api.core.hardware import detect_capabilities, resolve_execution
from find_api.core.model_manager import get_model_manager
+from find_api.core.runtime_profile import current_accel_mode
logger = logging.getLogger(__name__)
@@ -27,7 +27,7 @@ def _load_model(self):
# Resolve ONNX execution providers from the accel mode, with automatic
# CPU fallback (the EP list always ends with CPUExecutionProvider, and
# collapses to CPU-only when no GPU is available).
- plan = resolve_execution(settings.ACCEL_MODE, detect_capabilities())
+ plan = resolve_execution(current_accel_mode(), detect_capabilities())
providers = plan.providers
use_gpu = plan.using_gpu
@@ -53,7 +53,7 @@ def detect_faces(self, image: Union[Image.Image, np.ndarray]) -> List[Dict]:
# Convert PIL to BGR numpy array (cv2 format)
image = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
- config_key = f"model=antelopev2|accel={settings.ACCEL_MODE}"
+ config_key = f"model=antelopev2|accel={current_accel_mode()}"
with self.manager.use_model(
"insightface", self._load_model, config_key=config_key
) as app:
diff --git a/backend/src/find_api/ml/object_detector.py b/backend/src/find_api/ml/object_detector.py
index b5d8dca7..4b9dda6a 100644
--- a/backend/src/find_api/ml/object_detector.py
+++ b/backend/src/find_api/ml/object_detector.py
@@ -11,6 +11,7 @@
from find_api.core.config import settings
from find_api.core.hardware import current_torch_device
from find_api.core.model_manager import get_model_manager
+from find_api.core.runtime_profile import current_accel_mode
logger = logging.getLogger(__name__)
@@ -40,7 +41,7 @@ def detect(
"""
try:
config_key = (
- f"model={settings.YOLO_MODEL}|accel={settings.ACCEL_MODE}|"
+ f"model={settings.YOLO_MODEL}|accel={current_accel_mode()}|"
f"half={settings.YOLO_HALF}"
)
with self.manager.use_model(
diff --git a/backend/src/find_api/models/app_setting.py b/backend/src/find_api/models/app_setting.py
index e013e6da..480b45ee 100644
--- a/backend/src/find_api/models/app_setting.py
+++ b/backend/src/find_api/models/app_setting.py
@@ -1,15 +1,14 @@
"""Persisted application settings (key/value).
A tiny key/value store for runtime-adjustable preferences that should survive
-a restart without editing ``.env`` — currently the hardware-acceleration mode
-chosen in the settings panel. Kept as key/value (not a typed column per
+a restart without editing ``.env`` — currently AI enablement, hardware
+acceleration, and opt-in map/GPS processing. Kept as key/value (not a typed column per
setting) so adding a future setting needs no migration; values are stored as
strings and validated at the API boundary.
-Scope note: this persists the *preference*. The API process reads it back
-immediately (see routers/config.py), so the settings panel reflects the saved
-choice. Propagating a changed value into already-running ML worker processes is
-separate cross-process work and is intentionally out of scope here.
+Workers intentionally read these preferences at each job boundary. A setting
+change therefore affects newly started jobs without mutating process-wide
+Pydantic settings or interrupting in-flight inference.
"""
from sqlalchemy import Column, DateTime, String
diff --git a/backend/src/find_api/models/media.py b/backend/src/find_api/models/media.py
index 5503a0b2..f8a81fdd 100644
--- a/backend/src/find_api/models/media.py
+++ b/backend/src/find_api/models/media.py
@@ -81,6 +81,10 @@ class Media(Base):
width = Column(Integer)
height = Column(Integer)
exif_json = Column(JSON) # EXIF data
+ # Optional local-only GPS coordinates. They are populated only when the
+ # operator enables map metadata; no reverse-geocoding service is contacted.
+ latitude = Column(Float, nullable=True, index=True)
+ longitude = Column(Float, nullable=True, index=True)
# AI-generated metadata
metadata_json = Column(JSON) # Contains: caption, objects, ocr_text, faces, etc.
diff --git a/backend/src/find_api/routers/auth.py b/backend/src/find_api/routers/auth.py
index 0044755a..60f7f8b8 100644
--- a/backend/src/find_api/routers/auth.py
+++ b/backend/src/find_api/routers/auth.py
@@ -10,7 +10,7 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
-from fastapi import APIRouter, Depends, Header, HTTPException, Request
+from fastapi import APIRouter, Cookie, Depends, Header, HTTPException, Request, Response
from pydantic import BaseModel, Field, field_validator
from slowapi import Limiter
from slowapi.util import get_remote_address
@@ -27,7 +27,11 @@
)
from find_api.core.config import settings
from find_api.core.database import get_db
-from find_api.core.dependencies import get_admin_user, get_required_user
+from find_api.core.dependencies import (
+ SESSION_COOKIE_NAME,
+ get_admin_user,
+ get_required_user,
+)
from find_api.models.invite import InviteToken
from find_api.models.join_request import JoinRequest
from find_api.models.session import AuthSession
@@ -75,6 +79,23 @@ def password_fits_bcrypt(cls, v: str) -> str:
return v
+class ProfileUpdateRequest(BaseModel):
+ username: Optional[str] = Field(None, min_length=1, max_length=150)
+ display_name: Optional[str] = Field(None, max_length=255)
+
+
+class PasswordChangeRequest(BaseModel):
+ current_password: str = Field(..., min_length=1, max_length=128)
+ new_password: str = Field(..., min_length=8, max_length=128)
+
+ @field_validator("new_password")
+ @classmethod
+ def password_fits_bcrypt(cls, v: str) -> str:
+ if len(v.encode("utf-8")) > 72:
+ raise ValueError("Password must be 72 bytes or fewer when encoded as UTF-8")
+ return v
+
+
def _user_dict(user: User) -> dict:
"""Serialize a User to a safe dict (no password hash)."""
return {
@@ -93,12 +114,41 @@ def _ensure_admin_available(admin: Optional[User], db: Session) -> User:
raise HTTPException(403, "Admin access required")
+def _set_session_cookie(
+ response: Response, raw_token: str, expires_at: datetime
+) -> None:
+ """Set the browser session without exposing it to JavaScript."""
+ max_age = max(
+ 0,
+ int((expires_at - datetime.now(timezone.utc)).total_seconds()),
+ )
+ response.set_cookie(
+ key=SESSION_COOKIE_NAME,
+ value=raw_token,
+ max_age=max_age,
+ expires=expires_at,
+ path="/",
+ httponly=True,
+ secure=settings.ENVIRONMENT == "production",
+ samesite="lax",
+ )
+
+
+def _request_session_token(
+ authorization: Optional[str], session_cookie: Optional[str]
+) -> Optional[str]:
+ if authorization and authorization.startswith("Bearer "):
+ return authorization[len("Bearer ") :]
+ return session_cookie
+
+
# --- Instance setup ---
@router.post("/auth/setup")
def setup_instance(
body: SetupRequest,
+ response: Response,
db: Session = Depends(get_db),
):
"""Create the admin account and activate shared mode.
@@ -124,6 +174,7 @@ def setup_instance(
db.refresh(admin)
raw_token, expires_at = create_session(db, admin.id)
+ _set_session_cookie(response, raw_token, expires_at)
return {
"user": _user_dict(admin),
@@ -140,6 +191,7 @@ def setup_instance(
def login(
request: Request,
body: LoginRequest,
+ response: Response,
db: Session = Depends(get_db),
):
"""Authenticate with username and password.
@@ -161,6 +213,7 @@ def login(
db.commit()
raw_token, expires_at = create_session(db, user.id)
+ _set_session_cookie(response, raw_token, expires_at)
return {
"user": _user_dict(user),
@@ -171,17 +224,27 @@ def login(
@router.post("/auth/logout")
def logout(
+ response: Response,
user: Optional[User] = Depends(get_required_user),
authorization: Optional[str] = Header(None),
+ session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
db: Session = Depends(get_db),
):
"""End the current session."""
- if authorization and authorization.startswith("Bearer "):
- raw_token = authorization[len("Bearer ") :]
+ raw_token = _request_session_token(authorization, session_cookie)
+ if raw_token:
hashed = hash_token(raw_token)
db.query(AuthSession).filter(AuthSession.token_hash == hashed).delete()
db.commit()
+ response.delete_cookie(
+ key=SESSION_COOKIE_NAME,
+ path="/",
+ secure=settings.ENVIRONMENT == "production",
+ httponly=True,
+ samesite="lax",
+ )
+
return {"message": "Logged out"}
@@ -197,6 +260,144 @@ def get_me(
return {"mode": "shared", "user": _user_dict(user)}
+@router.get("/auth/status")
+def get_auth_status(db: Session = Depends(get_db)):
+ """Public bootstrap state used to choose local, setup, or login UI."""
+ shared = is_shared_mode(db)
+ return {
+ "mode": "shared" if shared else "local",
+ "setup_available": not shared,
+ }
+
+
+@router.patch("/auth/profile")
+def update_profile(
+ body: ProfileUpdateRequest,
+ user: Optional[User] = Depends(get_required_user),
+ db: Session = Depends(get_db),
+):
+ """Update the signed-in account's username and display name."""
+ if user is None:
+ raise HTTPException(400, "Accounts are only available in shared mode")
+
+ if body.username is not None:
+ username = body.username.strip()
+ if not username:
+ raise HTTPException(422, "Username cannot be blank")
+ duplicate = (
+ db.query(User).filter(User.username == username, User.id != user.id).first()
+ )
+ if duplicate is not None:
+ raise HTTPException(409, "Username is already in use")
+ user.username = username
+
+ if body.display_name is not None:
+ user.display_name = body.display_name.strip() or None
+
+ db.commit()
+ db.refresh(user)
+ return {"user": _user_dict(user)}
+
+
+@router.post("/auth/password")
+def change_password(
+ body: PasswordChangeRequest,
+ response: Response,
+ user: Optional[User] = Depends(get_required_user),
+ db: Session = Depends(get_db),
+):
+ """Change the password, revoke every old session, and issue a fresh one."""
+ if user is None:
+ raise HTTPException(400, "Accounts are only available in shared mode")
+ if not verify_password_constant_time(body.current_password, user.password_hash):
+ raise HTTPException(401, "Current password is incorrect")
+ if body.current_password == body.new_password:
+ raise HTTPException(422, "New password must be different")
+
+ user.password_hash = hash_password(body.new_password)
+ db.query(AuthSession).filter(AuthSession.user_id == user.id).delete(
+ synchronize_session=False
+ )
+ db.commit()
+
+ raw_token, expires_at = create_session(db, user.id)
+ _set_session_cookie(response, raw_token, expires_at)
+ return {
+ "message": "Password changed",
+ "token": raw_token,
+ "expires_at": expires_at.isoformat(),
+ }
+
+
+@router.get("/auth/sessions")
+def list_sessions(
+ user: Optional[User] = Depends(get_required_user),
+ authorization: Optional[str] = Header(None),
+ session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
+ db: Session = Depends(get_db),
+):
+ """List the signed-in user's server-side sessions without exposing tokens."""
+ if user is None:
+ return {"sessions": []}
+ raw_token = _request_session_token(authorization, session_cookie)
+ current_hash = hash_token(raw_token) if raw_token else None
+ now = datetime.now(timezone.utc)
+ sessions = (
+ db.query(AuthSession)
+ .filter(AuthSession.user_id == user.id, AuthSession.expires_at > now)
+ .order_by(AuthSession.created_at.desc(), AuthSession.id.desc())
+ .all()
+ )
+ return {
+ "sessions": [
+ {
+ "id": item.id,
+ "created_at": item.created_at.isoformat()
+ if item.created_at is not None
+ else None,
+ "expires_at": item.expires_at.isoformat(),
+ "current": item.token_hash == current_hash,
+ }
+ for item in sessions
+ ]
+ }
+
+
+@router.delete("/auth/sessions/{session_id}")
+def revoke_session(
+ session_id: int,
+ response: Response,
+ user: Optional[User] = Depends(get_required_user),
+ authorization: Optional[str] = Header(None),
+ session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME),
+ db: Session = Depends(get_db),
+):
+ """Revoke one session owned by the signed-in user."""
+ if user is None:
+ raise HTTPException(400, "Accounts are only available in shared mode")
+ item = (
+ db.query(AuthSession)
+ .filter(AuthSession.id == session_id, AuthSession.user_id == user.id)
+ .first()
+ )
+ if item is None:
+ raise HTTPException(404, "Session not found")
+
+ raw_token = _request_session_token(authorization, session_cookie)
+ is_current = bool(raw_token and item.token_hash == hash_token(raw_token))
+ db.delete(item)
+ db.commit()
+ if is_current:
+ response.delete_cookie(
+ key=SESSION_COOKIE_NAME,
+ path="/",
+ secure=settings.ENVIRONMENT == "production",
+ httponly=True,
+ samesite="lax",
+ )
+ return {"revoked": session_id, "current": is_current}
+
+
# --- Invite tokens ---
diff --git a/backend/src/find_api/routers/config.py b/backend/src/find_api/routers/config.py
index 7c0da651..bfd0f41b 100644
--- a/backend/src/find_api/routers/config.py
+++ b/backend/src/find_api/routers/config.py
@@ -12,14 +12,22 @@
from find_api.core.database import get_db
from find_api.core.dependencies import get_admin_user, get_required_user
from find_api.core.hardware import detect_capabilities, resolve_execution
+from find_api.core.runtime_profile import (
+ ACCEL_MODE_KEY,
+ AI_ENABLED_KEY,
+ MAP_ENABLED_KEY,
+ ML_MODE_KEY,
+ get_worker_process_status,
+ get_worker_runtime_status,
+ load_runtime_preferences,
+ resolve_runtime,
+ worker_health,
+)
from find_api.models.app_setting import AppSetting
from find_api.models.user import User
router = APIRouter()
-# The persisted-settings key for the hardware-acceleration mode. Kept narrow on
-# purpose (YAGNI): only settings the UI actually exposes + persists live here.
-ACCEL_MODE_KEY = "accel_mode"
_VALID_ACCEL_MODES = ("auto", "gpu", "cpu")
@@ -34,18 +42,51 @@ def _effective_accel_mode(db: Session) -> str:
return get_setting(db, ACCEL_MODE_KEY, settings.ACCEL_MODE)
+def _runtime_resolution(db: Session):
+ return resolve_runtime(load_runtime_preferences(db))
+
+
@router.get("/config")
def get_app_config(db: Session = Depends(get_db)):
"""
Return safe public application configuration
"""
+ runtime = _runtime_resolution(db)
return {
- "ml_mode": settings.ML_MODE,
- "accel_mode": _effective_accel_mode(db),
+ "ml_mode": runtime.applied_mode,
+ "configured_ml_mode": runtime.configured_mode,
+ "accel_mode": runtime.configured_accel_mode,
+ "ai_enabled": runtime.ai_enabled,
+ "map_enabled": runtime.map_enabled,
+ "build_profile": runtime.build_profile,
+ "supported_ml_modes": list(runtime.supported_modes),
}
+@router.get("/config/runtime")
+def get_runtime_config(db: Session = Depends(get_db)):
+ """Report installed capabilities, desired state, and worker-applied state."""
+ runtime = _runtime_resolution(db)
+ report = detect_capabilities()
+ plan = resolve_execution(runtime.configured_accel_mode, report)
+ worker_process = get_worker_process_status()
+ payload = runtime.to_worker_status(source="database")
+ payload.update(
+ {
+ "hardware": {
+ "capabilities": report.to_dict(),
+ "resolved": plan.to_dict(),
+ },
+ "worker": {
+ "health": worker_health(worker_process),
+ "applied": get_worker_runtime_status(),
+ },
+ }
+ )
+ return payload
+
+
@router.get("/config/hardware")
def get_hardware_capabilities(db: Session = Depends(get_db)):
"""Report detected accelerators + the execution plan for the current mode.
@@ -54,7 +95,7 @@ def get_hardware_capabilities(db: Session = Depends(get_db)):
whether the chosen mode resolves to GPU or has fallen back to CPU. The mode
is the persisted preference when set, else the env default.
"""
- mode = _effective_accel_mode(db)
+ mode = load_runtime_preferences(db).accel_mode
report = detect_capabilities()
plan = resolve_execution(mode, report)
return {
@@ -65,11 +106,37 @@ def get_hardware_capabilities(db: Session = Depends(get_db)):
class SettingsResponse(BaseModel):
- accel_mode: str
+ accel_mode: Literal["auto", "gpu", "cpu"]
+ ai_enabled: bool
+ map_enabled: bool
+ ml_mode: Literal["disabled", "full", "mock", "remote"]
+ supported_ml_modes: list[str]
class SettingsUpdate(BaseModel):
accel_mode: Optional[Literal["auto", "gpu", "cpu"]] = None
+ ai_enabled: Optional[bool] = None
+ map_enabled: Optional[bool] = None
+ ml_mode: Optional[Literal["disabled", "full", "mock", "remote"]] = None
+
+
+def _settings_response(db: Session) -> SettingsResponse:
+ preferences = load_runtime_preferences(db)
+ return SettingsResponse(
+ accel_mode=preferences.accel_mode,
+ ai_enabled=preferences.ai_enabled,
+ map_enabled=preferences.map_enabled,
+ ml_mode=preferences.ml_mode,
+ supported_ml_modes=list(resolve_runtime(preferences).supported_modes),
+ )
+
+
+def _upsert_setting(db: Session, key: str, value: str) -> None:
+ row = db.query(AppSetting).filter(AppSetting.key == key).first()
+ if row is None:
+ db.add(AppSetting(key=key, value=value))
+ else:
+ row.value = value
@router.get("/settings", response_model=SettingsResponse)
@@ -82,7 +149,7 @@ def get_settings(
Readable by any authenticated user (or anyone in local mode) so the
settings panel can show the saved values.
"""
- return SettingsResponse(accel_mode=_effective_accel_mode(db))
+ return _settings_response(db)
@router.put("/settings", response_model=SettingsResponse)
@@ -102,11 +169,24 @@ def update_settings(
# Defensive: Literal already constrains this, but guard the raw write.
if request.accel_mode not in _VALID_ACCEL_MODES:
raise HTTPException(422, "accel_mode must be one of auto, gpu, cpu")
- row = db.query(AppSetting).filter(AppSetting.key == ACCEL_MODE_KEY).first()
- if row is None:
- db.add(AppSetting(key=ACCEL_MODE_KEY, value=request.accel_mode))
- else:
- row.value = request.accel_mode
+ _upsert_setting(db, ACCEL_MODE_KEY, request.accel_mode)
+
+ if request.ai_enabled is not None:
+ _upsert_setting(db, AI_ENABLED_KEY, str(request.ai_enabled).lower())
+
+ if request.map_enabled is not None:
+ _upsert_setting(db, MAP_ENABLED_KEY, str(request.map_enabled).lower())
+
+ if request.ml_mode is not None:
+ modes = resolve_runtime(load_runtime_preferences(db)).supported_modes
+ if request.ml_mode not in modes:
+ raise HTTPException(
+ 422,
+ f"ML mode '{request.ml_mode}' is not installed in this artifact",
+ )
+ _upsert_setting(db, ML_MODE_KEY, request.ml_mode)
+
+ if request.model_fields_set:
db.commit()
- return SettingsResponse(accel_mode=_effective_accel_mode(db))
+ return _settings_response(db)
diff --git a/backend/src/find_api/routers/gallery.py b/backend/src/find_api/routers/gallery.py
index 8baee225..19776efc 100644
--- a/backend/src/find_api/routers/gallery.py
+++ b/backend/src/find_api/routers/gallery.py
@@ -578,6 +578,28 @@ def get_image_thumbnail(
return RedirectResponse(url=url)
+@router.get("/image/{media_id}/original")
+def get_image_original(
+ media_id: int,
+ db: Session = Depends(get_db),
+ user: Optional[User] = Depends(get_required_user),
+):
+ """Return a scoped redirect to the original image object.
+
+ Viewer components need a media response rather than the JSON detail route.
+ Keeping this behind the same auth/IDOR checks also avoids exposing the
+ object-store key or making the bucket public.
+ """
+ media = _load_public_media_or_404(db, media_id)
+ if not can_access_media(media, user):
+ raise HTTPException(404, "Image not found")
+ try:
+ url = get_file_url(media.minio_key)
+ except Exception as exc:
+ raise HTTPException(500, "Could not generate image URL") from exc
+ return RedirectResponse(url=url)
+
+
@router.post("/thumbnails/backfill")
def backfill_missing_thumbnails(
limit: int = Query(100, ge=1, le=1000),
diff --git a/backend/src/find_api/routers/map.py b/backend/src/find_api/routers/map.py
new file mode 100644
index 00000000..6dfe9b42
--- /dev/null
+++ b/backend/src/find_api/routers/map.py
@@ -0,0 +1,111 @@
+"""Private, local-only map marker endpoints.
+
+Coordinates come from opt-in EXIF extraction. The API never reverse geocodes,
+contacts a tile provider, or returns hidden/vault media.
+"""
+
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import or_
+from sqlalchemy.orm import Session
+
+from find_api.core.database import get_db
+from find_api.core.dependencies import get_required_user, scope_media_query
+from find_api.core.runtime_profile import load_runtime_preferences
+from find_api.models.media import Media
+from find_api.models.user import User
+from find_api.routers.gallery import _public_media_query, build_thumbnail_url
+
+router = APIRouter()
+
+
+def _map_enabled(db: Session) -> bool:
+ return load_runtime_preferences(db).map_enabled
+
+
+def _marker_query(
+ db: Session,
+ user: Optional[User],
+ *,
+ include_archived: bool,
+ liked: Optional[bool],
+ west: Optional[float],
+ south: Optional[float],
+ east: Optional[float],
+ north: Optional[float],
+):
+ query = scope_media_query(_public_media_query(db), user).filter(
+ Media.deleted_at.is_(None),
+ Media.vault_state == "visible",
+ Media.latitude.isnot(None),
+ Media.longitude.isnot(None),
+ )
+ if not include_archived:
+ query = query.filter(Media.is_archived.is_(False))
+ if liked is not None:
+ query = query.filter(Media.liked == liked)
+ if south is not None:
+ query = query.filter(Media.latitude >= south)
+ if north is not None:
+ query = query.filter(Media.latitude <= north)
+ if west is not None and east is not None:
+ if west <= east:
+ query = query.filter(Media.longitude >= west, Media.longitude <= east)
+ else:
+ # Bounding boxes crossing the antimeridian wrap around ±180°.
+ query = query.filter(or_(Media.longitude >= west, Media.longitude <= east))
+ elif west is not None:
+ query = query.filter(Media.longitude >= west)
+ elif east is not None:
+ query = query.filter(Media.longitude <= east)
+ return query
+
+
+@router.get("/map/markers")
+def get_map_markers(
+ include_archived: bool = False,
+ liked: Optional[bool] = None,
+ west: Optional[float] = Query(None, ge=-180, le=180),
+ south: Optional[float] = Query(None, ge=-90, le=90),
+ east: Optional[float] = Query(None, ge=-180, le=180),
+ north: Optional[float] = Query(None, ge=-90, le=90),
+ db: Session = Depends(get_db),
+ user: Optional[User] = Depends(get_required_user),
+):
+ """Return scoped media coordinates for local client-side clustering."""
+ if not _map_enabled(db):
+ return {"enabled": False, "markers": [], "total": 0}
+
+ rows = (
+ _marker_query(
+ db,
+ user,
+ include_archived=include_archived,
+ liked=liked,
+ west=west,
+ south=south,
+ east=east,
+ north=north,
+ )
+ .order_by(Media.created_at.desc(), Media.id.desc())
+ .all()
+ )
+ markers = [
+ {
+ "id": media.id,
+ "lat": media.latitude,
+ "lon": media.longitude,
+ "filename": media.filename,
+ "created_at": media.created_at.isoformat()
+ if media.created_at is not None
+ else None,
+ "thumbnail_url": build_thumbnail_url(media.id),
+ "ratio": round(media.width / media.height, 4)
+ if media.width and media.height
+ else None,
+ "liked": bool(media.liked),
+ }
+ for media in rows
+ ]
+ return {"enabled": True, "markers": markers, "total": len(markers)}
diff --git a/backend/src/find_api/routers/search.py b/backend/src/find_api/routers/search.py
index 3611352a..a6e22c49 100644
--- a/backend/src/find_api/routers/search.py
+++ b/backend/src/find_api/routers/search.py
@@ -7,7 +7,7 @@
from typing import Any, Literal
from urllib.parse import quote
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -25,6 +25,12 @@
from find_api.core.storage import get_file_url
from find_api.routers.gallery import build_thumbnail_url, parse_metadata_date
from find_api.services.query_cache import get_cached_query, set_cached_query
+from find_api.core.runtime_profile import (
+ bind_runtime,
+ load_runtime_preferences,
+ reset_runtime,
+ resolve_runtime,
+)
from typing import Optional
router = APIRouter()
@@ -192,6 +198,16 @@ def search_images(
Paginated list of matching images with metadata for frontend navigation.
"""
t_total_start = time.perf_counter()
+ runtime = resolve_runtime(load_runtime_preferences(db))
+ if runtime.applied_mode == "disabled":
+ raise HTTPException(409, "AI search is disabled in instance settings.")
+ if runtime.applied_mode == "unavailable":
+ raise HTTPException(
+ 503,
+ runtime.unavailable_reason
+ or "The configured AI search runtime is unavailable.",
+ )
+
metadata_filter_sql, metadata_filter_params, filter_key = _metadata_filter_sql(
camera_make=camera_make,
camera_model=camera_model,
@@ -216,7 +232,10 @@ def search_images(
index_signature = _search_index_signature(db)
# Partition the cache by scope so one user cannot read another's
# cached results in shared mode.
- scoped_signature = f"{index_signature}:scope={scope_user_id}"
+ scoped_signature = (
+ f"{index_signature}:scope={scope_user_id}:"
+ f"runtime={runtime.applied_mode}:accel={runtime.configured_accel_mode}"
+ )
cached = get_cached_query(
q,
limit,
@@ -229,18 +248,22 @@ def search_images(
return cached["response"]
# Generate query embedding
- if settings.ML_MODE.lower() == "mock":
- from find_api.ml.mock_embedder import get_mock_embedder
+ runtime_tokens = bind_runtime(runtime)
+ try:
+ if runtime.applied_mode == "mock":
+ from find_api.ml.mock_embedder import get_mock_embedder
- embedder = get_mock_embedder()
- else:
- from find_api.ml.clip_embedder import get_clip_embedder
+ embedder = get_mock_embedder()
+ else:
+ from find_api.ml.clip_embedder import get_clip_embedder
- embedder = get_clip_embedder()
+ embedder = get_clip_embedder()
- t_embed_start = time.perf_counter()
- query_embedding = embedder.embed_text(q)
- embedding_ms = (time.perf_counter() - t_embed_start) * 1000
+ t_embed_start = time.perf_counter()
+ query_embedding = embedder.embed_text(q)
+ embedding_ms = (time.perf_counter() - t_embed_start) * 1000
+ finally:
+ reset_runtime(runtime_tokens)
# Convert to string format for pgvector
embedding_str = "[" + ",".join(map(str, query_embedding)) + "]"
@@ -250,7 +273,7 @@ def search_images(
# Added threshold to filter irrelevant results
threshold = (
MOCK_SIMILARITY_THRESHOLD
- if settings.ML_MODE.lower() == "mock"
+ if runtime.applied_mode == "mock"
else FULL_SIMILARITY_THRESHOLD
)
@@ -430,7 +453,7 @@ def search_images(
"total_ms": round(total_ms, 2),
"results_returned": len(results),
"similarity_threshold": threshold,
- "ml_mode": settings.ML_MODE,
+ "ml_mode": runtime.applied_mode,
}
if not debug:
@@ -438,7 +461,11 @@ def search_images(
q,
limit,
skip,
- f"{index_signature or ''}:scope={scope_user_id}",
+ (
+ f"{index_signature or ''}:scope={scope_user_id}:"
+ f"runtime={runtime.applied_mode}:"
+ f"accel={runtime.configured_accel_mode}"
+ ),
query_embedding,
response,
include_ocr=include_ocr,
diff --git a/backend/src/find_api/routers/status.py b/backend/src/find_api/routers/status.py
index ed3063f2..9bb7645d 100644
--- a/backend/src/find_api/routers/status.py
+++ b/backend/src/find_api/routers/status.py
@@ -11,6 +11,7 @@
from find_api.core.dependencies import get_admin_user, get_required_user
from find_api.core.model_manager import get_model_manager
from find_api.core.queue import get_job, get_redis_connection
+from find_api.core.runtime_profile import worker_health
from find_api.models.user import User
router = APIRouter()
@@ -52,10 +53,13 @@ def get_loaded_models(_admin: Optional[User] = Depends(get_admin_user)):
}
)
+ worker_status = process_status.get("worker")
return {
"loaded_models": loaded_models,
"processes": process_status,
"ttl_seconds": settings.ML_MODEL_IDLE_TTL_SECONDS,
+ "worker_runtime": worker_status.get("runtime") if worker_status else None,
+ "worker_health": worker_health(worker_status),
}
diff --git a/backend/src/find_api/routers/vault.py b/backend/src/find_api/routers/vault.py
index 563a62a6..88f1913e 100644
--- a/backend/src/find_api/routers/vault.py
+++ b/backend/src/find_api/routers/vault.py
@@ -3,15 +3,18 @@
from __future__ import annotations
import os
+import logging
import secrets
import tempfile
+from io import BytesIO
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
-from fastapi.responses import StreamingResponse
+from fastapi.responses import Response, StreamingResponse
+from PIL import Image, ImageOps, UnidentifiedImageError
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -25,21 +28,29 @@
delete_session_key,
decrypt_file_stream,
derive_master_key,
- verify_master_key,
- get_session_key,
encrypt_file,
+ get_session_key,
set_session_key,
+ verify_master_key,
)
from find_api.core.database import get_db
from find_api.core.dependencies import get_required_user
-from find_api.core.storage import delete_file, download_file_to_path
+from find_api.core.storage import (
+ delete_file,
+ download_file_to_path,
+ upload_file,
+ upload_thumbnail,
+)
from find_api.models.media import Media
from find_api.models.user import User
router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
+logger = logging.getLogger(__name__)
ENCRYPTION_ALGORITHM = "AES-256-GCM"
-KEY_DERIVATION_METHOD = "PBKDF2-HMAC-SHA256"
+KEY_DERIVATION_METHOD = "Argon2id"
+VAULT_THUMBNAIL_MAX_SIZE = (320, 320)
+VAULT_THUMBNAIL_CONTENT_TYPE = "image/webp"
# Minimum passphrase length enforced when a vault is first created. Existing
# vaults are not re-validated on unlock so short legacy passphrases keep working.
@@ -59,6 +70,11 @@ class VaultHideRequest(BaseModel):
session_token: Optional[str] = None
+class VaultRestoreRequest(BaseModel):
+ media_id: int
+ session_token: Optional[str] = None
+
+
def _normalize_binary(value: object) -> bytes:
"""Convert database BLOB-like values to plain bytes."""
if isinstance(value, bytes):
@@ -191,9 +207,7 @@ def _load_vault_metadata(db: Session, media_id: int) -> Optional[tuple[str, byte
"""Return encrypted vault blob metadata for a media row."""
row = db.execute(
text(
- "SELECT encrypted_path, iv "
- "FROM vault_metadata "
- "WHERE media_id = :media_id"
+ "SELECT encrypted_path, iv FROM vault_metadata WHERE media_id = :media_id"
),
{"media_id": media_id},
).first()
@@ -202,10 +216,53 @@ def _load_vault_metadata(db: Session, media_id: int) -> Optional[tuple[str, byte
return row[0], _normalize_binary(row[1])
+def _apply_thumbnail_metadata(media: Media, metadata: Optional[dict]) -> None:
+ """Apply generated thumbnail metadata or clear stale thumbnail pointers."""
+ media.thumbnail_key = metadata.get("thumbnail_key") if metadata else None
+ media.thumbnail_content_type = (
+ metadata.get("thumbnail_content_type") if metadata else None
+ )
+ media.thumbnail_size = metadata.get("thumbnail_size") if metadata else None
+ media.thumbnail_width = metadata.get("thumbnail_width") if metadata else None
+ media.thumbnail_height = metadata.get("thumbnail_height") if metadata else None
+
+
+def _delete_storage_objects_best_effort(*object_keys: Optional[str]) -> None:
+ """Remove rollback artifacts without masking the primary failure."""
+ for object_key in dict.fromkeys(key for key in object_keys if key):
+ try:
+ delete_file(object_key)
+ except Exception: # noqa: BLE001
+ logger.exception("Failed to remove vault rollback object %s", object_key)
+
+
def _rollback_hidden_state_after_delete_failure(
- db: Session, media: Media, encrypted_path: Path
-) -> None:
- """Best-effort rollback when original object deletion fails after encryption."""
+ db: Session,
+ media: Media,
+ encrypted_path: Path,
+ plaintext_path: Path,
+ *,
+ original_key: str,
+ content_type: str,
+ file_hash: str,
+ had_thumbnail: bool,
+) -> bool:
+ """Restore plaintext storage before making a failed hide visible again.
+
+ The encrypted blob and hidden database state remain authoritative until the
+ original object has been replaced successfully. This prevents a storage
+ deletion failure from leaving a visible row whose object no longer exists.
+ """
+ uploaded_thumbnail: Optional[dict] = None
+ try:
+ plaintext = plaintext_path.read_bytes()
+ upload_file(plaintext, original_key, content_type)
+ if had_thumbnail:
+ uploaded_thumbnail = upload_thumbnail(plaintext, file_hash)
+ except Exception: # noqa: BLE001
+ logger.exception("Failed to restore plaintext while rolling back vault hide")
+ return False
+
try:
db.execute(
text("DELETE FROM vault_metadata WHERE media_id = :media_id"),
@@ -215,11 +272,61 @@ def _rollback_hidden_state_after_delete_failure(
media.vault_state = "visible"
media.hidden_at = None
media.encrypted_at = None
+ if had_thumbnail:
+ _apply_thumbnail_metadata(media, uploaded_thumbnail)
db.commit()
except Exception: # noqa: BLE001
db.rollback()
- finally:
- encrypted_path.unlink(missing_ok=True)
+ _delete_storage_objects_best_effort(
+ original_key,
+ uploaded_thumbnail.get("thumbnail_key") if uploaded_thumbnail else None,
+ )
+ return False
+
+ encrypted_path.unlink(missing_ok=True)
+ return True
+
+
+def _decrypt_to_temporary_path(
+ master_key: bytes,
+ iv: bytes,
+ encrypted_file: Path,
+ *,
+ associated_data: bytes,
+ prefix: str,
+) -> Path:
+ """Decrypt and authenticate a vault blob into a short-lived local file."""
+ fd, plaintext_path = tempfile.mkstemp(prefix=prefix)
+ os.close(fd)
+ plaintext_file = Path(plaintext_path)
+
+ try:
+ with plaintext_file.open("wb") as output:
+ for chunk in decrypt_file_stream(
+ master_key,
+ iv,
+ str(encrypted_file),
+ associated_data=associated_data,
+ ):
+ output.write(chunk)
+ except Exception:
+ plaintext_file.unlink(missing_ok=True)
+ raise
+
+ return plaintext_file
+
+
+def _render_vault_thumbnail(plaintext_file: Path) -> bytes:
+ """Render a bounded WEBP preview without returning the decrypted original."""
+ with Image.open(plaintext_file) as source:
+ image = ImageOps.exif_transpose(source)
+ if image.mode not in {"RGB", "RGBA"}:
+ image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
+ image.thumbnail(VAULT_THUMBNAIL_MAX_SIZE, Image.Resampling.LANCZOS)
+
+ output = BytesIO()
+ image.save(output, format="WEBP", quality=76, method=4)
+ return output.getvalue()
@router.post("/vault/unlock")
@@ -275,7 +382,10 @@ def list_vault_media(
"id": media.id,
"filename": media.filename,
"content_type": media.content_type,
+ "width": media.width,
+ "height": media.height,
"created_at": media.created_at.isoformat() if media.created_at else None,
+ "hidden_at": media.hidden_at.isoformat() if media.hidden_at else None,
}
for media in media_items
]
@@ -313,6 +423,10 @@ def hide_media(
if existing_metadata is not None:
raise HTTPException(status_code=409, detail="Vault metadata already exists")
+ original_key = media.minio_key
+ thumbnail_key = media.thumbnail_key
+ content_type = media.content_type or "application/octet-stream"
+ file_hash = media.file_hash
encrypted_path = VAULT_STORAGE_DIR / f"{media.id}-{uuid4().hex}.enc"
encrypted_path.parent.mkdir(parents=True, exist_ok=True)
@@ -321,8 +435,8 @@ def hide_media(
try:
try:
- download_file_to_path(media.minio_key, temp_source_path)
- aad = build_vault_aad(media.id, media.file_hash)
+ download_file_to_path(original_key, temp_source_path)
+ aad = build_vault_aad(media.id, file_hash)
iv = encrypt_file(
master_key,
temp_source_path,
@@ -357,12 +471,27 @@ def hide_media(
raise HTTPException(status_code=500, detail="Failed to hide image") from exc
try:
- delete_file(media.minio_key)
+ delete_file(original_key)
+ if thumbnail_key:
+ delete_file(thumbnail_key)
except Exception as exc: # noqa: BLE001
- _rollback_hidden_state_after_delete_failure(db, media, encrypted_path)
+ rolled_back = _rollback_hidden_state_after_delete_failure(
+ db,
+ media,
+ encrypted_path,
+ Path(temp_source_path),
+ original_key=original_key,
+ content_type=content_type,
+ file_hash=file_hash,
+ had_thumbnail=thumbnail_key is not None,
+ )
raise HTTPException(
status_code=500,
- detail="Failed to remove original image from storage",
+ detail=(
+ "Failed to remove plaintext image from storage"
+ if rolled_back
+ else "Failed to remove plaintext image safely; encrypted copy retained"
+ ),
) from exc
finally:
Path(temp_source_path).unlink(missing_ok=True)
@@ -370,6 +499,165 @@ def hide_media(
return {"status": "hidden", "media_id": media.id}
+@router.post("/vault/restore")
+def restore_media(
+ payload: VaultRestoreRequest,
+ authorization: Optional[str] = Header(default=None),
+ db: Session = Depends(get_db),
+):
+ """Restore an authenticated vault item to configured storage safely.
+
+ The encrypted blob remains the rollback source until storage replacement
+ and the visible-state database transaction both succeed. It is staged by an
+ atomic local rename while the database transition commits, then deleted.
+ """
+ session_token = _resolve_session_token(authorization, payload.session_token)
+ master_key = _get_cached_master_key(session_token)
+
+ media = _load_media_or_404(db, payload.media_id)
+ if not media.is_hidden:
+ raise HTTPException(status_code=409, detail="Image is not hidden")
+
+ metadata = _load_vault_metadata(db, media.id)
+ if metadata is None:
+ raise HTTPException(status_code=404, detail="Vault metadata not found")
+
+ encrypted_path, iv = metadata
+ encrypted_file = Path(encrypted_path)
+ if not encrypted_file.exists():
+ raise HTTPException(status_code=404, detail="Encrypted vault blob not found")
+
+ aad = build_vault_aad(media.id, media.file_hash)
+ plaintext_file = _decrypt_to_temporary_path(
+ master_key,
+ iv,
+ encrypted_file,
+ associated_data=aad,
+ prefix=f"vault-restore-{media.id}-",
+ )
+
+ original_key = media.minio_key
+ content_type = media.content_type or "application/octet-stream"
+ thumbnail_metadata: Optional[dict] = None
+ staged_encrypted = encrypted_file.with_name(
+ f".{encrypted_file.name}.{uuid4().hex}.restore-pending"
+ )
+
+ try:
+ plaintext = plaintext_file.read_bytes()
+ try:
+ upload_file(plaintext, original_key, content_type)
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(
+ status_code=500,
+ detail="Failed to restore image to configured storage",
+ ) from exc
+
+ try:
+ thumbnail_metadata = upload_thumbnail(plaintext, media.file_hash)
+ except Exception: # noqa: BLE001
+ logger.exception("Failed to regenerate thumbnail for restored vault item")
+
+ try:
+ encrypted_file.replace(staged_encrypted)
+ except Exception as exc: # noqa: BLE001
+ _delete_storage_objects_best_effort(
+ original_key,
+ thumbnail_metadata.get("thumbnail_key") if thumbnail_metadata else None,
+ )
+ raise HTTPException(
+ status_code=500,
+ detail="Failed to stage encrypted vault blob for restore",
+ ) from exc
+
+ try:
+ db.execute(
+ text("DELETE FROM vault_metadata WHERE media_id = :media_id"),
+ {"media_id": media.id},
+ )
+ media.is_hidden = False
+ media.vault_state = "visible"
+ media.hidden_at = None
+ media.encrypted_at = None
+ _apply_thumbnail_metadata(media, thumbnail_metadata)
+ db.commit()
+ except Exception as exc: # noqa: BLE001
+ db.rollback()
+ try:
+ staged_encrypted.replace(encrypted_file)
+ except Exception: # noqa: BLE001
+ logger.exception("Failed to restore staged encrypted vault blob")
+ _delete_storage_objects_best_effort(
+ original_key,
+ thumbnail_metadata.get("thumbnail_key") if thumbnail_metadata else None,
+ )
+ raise HTTPException(
+ status_code=500,
+ detail="Failed to commit restored image state",
+ ) from exc
+
+ encrypted_blob_removed = True
+ try:
+ staged_encrypted.unlink()
+ except Exception: # noqa: BLE001
+ encrypted_blob_removed = False
+ logger.exception("Restored vault item left an encrypted cleanup artifact")
+
+ return {
+ "status": "restored",
+ "media_id": media.id,
+ "encrypted_blob_removed": encrypted_blob_removed,
+ }
+ finally:
+ plaintext_file.unlink(missing_ok=True)
+
+
+@router.get("/vault/thumbnail/{media_id}")
+def thumbnail_hidden_media(
+ media_id: int,
+ authorization: Optional[str] = Header(default=None),
+ db: Session = Depends(get_db),
+):
+ """Return a small authenticated preview without exposing the original."""
+ token = _resolve_session_token(authorization, None)
+ master_key = _get_cached_master_key(token)
+ media = _load_media_or_404(db, media_id)
+ if not media.is_hidden:
+ raise HTTPException(status_code=404, detail="Image not found")
+
+ metadata = _load_vault_metadata(db, media_id)
+ if metadata is None:
+ raise HTTPException(status_code=404, detail="Vault metadata not found")
+
+ encrypted_path, iv = metadata
+ encrypted_file = Path(encrypted_path)
+ if not encrypted_file.exists():
+ raise HTTPException(status_code=404, detail="Encrypted vault blob not found")
+
+ aad = build_vault_aad(media.id, media.file_hash)
+ plaintext_file = _decrypt_to_temporary_path(
+ master_key,
+ iv,
+ encrypted_file,
+ associated_data=aad,
+ prefix=f"vault-thumbnail-{media.id}-",
+ )
+ try:
+ thumbnail = _render_vault_thumbnail(plaintext_file)
+ except (OSError, UnidentifiedImageError) as exc:
+ raise HTTPException(
+ status_code=422, detail="Vault image preview failed"
+ ) from exc
+ finally:
+ plaintext_file.unlink(missing_ok=True)
+
+ return Response(
+ content=thumbnail,
+ media_type=VAULT_THUMBNAIL_CONTENT_TYPE,
+ headers={"Cache-Control": "private, no-store", "Pragma": "no-cache"},
+ )
+
+
@router.get("/vault/stream/{media_id}")
def stream_hidden_media(
media_id: int,
@@ -400,4 +688,5 @@ def stream_hidden_media(
associated_data=aad,
),
media_type=media.content_type or "application/octet-stream",
+ headers={"Cache-Control": "private, no-store", "Pragma": "no-cache"},
)
diff --git a/backend/src/find_api/utils/exif.py b/backend/src/find_api/utils/exif.py
index 15d23bc4..24f5f8c7 100644
--- a/backend/src/find_api/utils/exif.py
+++ b/backend/src/find_api/utils/exif.py
@@ -4,11 +4,50 @@
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
-from typing import Dict, Any
+from typing import Dict, Any, Optional
import logging
logger = logging.getLogger(__name__)
+GPS_INFO_TAG = 0x8825
+
+
+def _gps_degrees(value: Any, reference: Any) -> Optional[float]:
+ """Convert an EXIF DMS tuple into signed decimal degrees."""
+ try:
+ degrees, minutes, seconds = (float(part) for part in value)
+ coordinate = degrees + minutes / 60 + seconds / 3600
+ if str(reference).upper() in {"S", "W"}:
+ coordinate *= -1
+ return coordinate
+ except (TypeError, ValueError, ZeroDivisionError):
+ return None
+
+
+def extract_gps_coordinates(image: Image.Image) -> Optional[tuple[float, float]]:
+ """Return ``(latitude, longitude)`` from EXIF, without network geocoding.
+
+ Callers must opt in before storing this sensitive location metadata. The
+ helper intentionally does no reverse geocoding or tile/provider request.
+ """
+ try:
+ exif = image.getexif()
+ if not exif:
+ return None
+ gps_ifd = exif.get_ifd(GPS_INFO_TAG)
+ if not gps_ifd:
+ return None
+ latitude = _gps_degrees(gps_ifd.get(2), gps_ifd.get(1))
+ longitude = _gps_degrees(gps_ifd.get(4), gps_ifd.get(3))
+ if latitude is None or longitude is None:
+ return None
+ if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
+ return None
+ return (round(latitude, 7), round(longitude, 7))
+ except Exception as exc: # malformed EXIF must not fail image indexing
+ logger.warning("Failed to parse GPS coordinates: %s", exc)
+ return None
+
def extract_exif_data(
image: Image.Image, *, include_gps: bool = False
diff --git a/backend/src/find_api/workers/jobs.py b/backend/src/find_api/workers/jobs.py
index db3c79dd..6556f858 100644
--- a/backend/src/find_api/workers/jobs.py
+++ b/backend/src/find_api/workers/jobs.py
@@ -6,7 +6,6 @@
import io
import logging
from datetime import datetime, timezone
-import numpy as np
from rq import get_current_job
from find_api.core.database import SessionLocal
@@ -17,10 +16,17 @@
)
from find_api.core.storage import get_file, upload_thumbnail
from find_api.core.model_manager import get_model_manager
+from find_api.core.runtime_profile import (
+ RuntimeUnavailableError,
+ bind_runtime,
+ load_runtime_preferences,
+ reset_runtime,
+ resolve_runtime,
+)
from find_api.core.config import settings
from find_api.models.media import Media
from find_api.services.query_cache import invalidate_query_cache
-from find_api.utils.exif import extract_exif_data
+from find_api.utils.exif import extract_exif_data, extract_gps_coordinates
from find_api.utils.errors import sanitize_error
from sqlalchemy import func
@@ -41,8 +47,38 @@
ANALYSIS_MODEL_NAMES = ("yolo", "florence-2", "paddleocr", "siglip", "insightface")
-def cosine_similarity(left: np.ndarray, right: np.ndarray) -> float:
+def _begin_worker_runtime(db):
+ """Bind and publish one persisted runtime snapshot for a worker job."""
+ resolution = resolve_runtime(load_runtime_preferences(db))
+ tokens = bind_runtime(resolution)
+ get_model_manager().set_runtime_status(
+ resolution.to_worker_status(source="database")
+ )
+ return resolution, tokens
+
+
+def _metadata_only_payload(profile: str) -> dict:
+ skipped = {"status": "skipped", "error": None}
+ return {
+ "caption": "",
+ "objects": [],
+ "ocr_text": "",
+ "text_blocks": [],
+ "ai_disabled": True,
+ "runtime_profile": profile,
+ "stage_status": {
+ "object_detection": skipped.copy(),
+ "captioning": skipped.copy(),
+ "ocr": skipped.copy(),
+ "embedding": skipped.copy(),
+ },
+ }
+
+
+def cosine_similarity(left, right) -> float:
"""Return cosine similarity for two vectors, guarding empty norms."""
+ import numpy as np
+
left_norm = np.linalg.norm(left)
right_norm = np.linalg.norm(right)
if left_norm == 0 or right_norm == 0:
@@ -103,16 +139,12 @@ def analyze_image(media_id: int, clear_model_failures: bool = False):
Main worker job to analyze an uploaded image
"""
- from find_api.workers.processors import (
- extract_image_metadata,
- generate_hybrid_embedding,
- )
-
job = get_current_job()
db = SessionLocal()
media = None
metadata = None
+ runtime_tokens = None
try:
if clear_model_failures:
@@ -125,6 +157,10 @@ def analyze_image(media_id: int, clear_model_failures: bool = False):
logger.error(f"Media {media_id} not found")
return
+ runtime, runtime_tokens = _begin_worker_runtime(db)
+ if runtime.applied_mode == "unavailable":
+ raise RuntimeUnavailableError(runtime)
+
media.status = "processing"
db.commit()
@@ -146,33 +182,48 @@ def analyze_image(media_id: int, clear_model_failures: bool = False):
set_stage(job, "extracting EXIF")
try:
- exif_data = extract_exif_data(image)
+ exif_data = extract_exif_data(image, include_gps=runtime.map_enabled)
media.exif_json = exif_data
+ coordinates = (
+ extract_gps_coordinates(image) if runtime.map_enabled else None
+ )
+ media.latitude = coordinates[0] if coordinates else None
+ media.longitude = coordinates[1] if coordinates else None
except Exception as e:
logger.warning(f"Failed to extract EXIF: {e}")
media.exif_json = {}
- metadata = extract_image_metadata(
- image,
- on_stage=lambda stage: set_stage(job, stage),
- )
+ if runtime.applied_mode == "disabled":
+ set_stage(job, "indexing metadata only")
+ metadata = _metadata_only_payload(runtime.build_profile)
+ media.vector = None
+ else:
+ from find_api.workers.processors import (
+ extract_image_metadata,
+ generate_hybrid_embedding,
+ )
- set_stage(job, "generating embedding")
+ metadata = extract_image_metadata(
+ image,
+ on_stage=lambda stage: set_stage(job, stage),
+ )
- try:
- media.vector = generate_hybrid_embedding(image, metadata)
- if "stage_status" in metadata:
- metadata["stage_status"]["embedding"] = {
- "status": "success",
- "error": None,
- }
- except Exception as e:
- if "stage_status" in metadata:
- metadata["stage_status"]["embedding"] = {
- "status": "failed",
- "error": sanitize_error(e),
- }
- raise
+ set_stage(job, "generating embedding")
+
+ try:
+ media.vector = generate_hybrid_embedding(image, metadata)
+ if "stage_status" in metadata:
+ metadata["stage_status"]["embedding"] = {
+ "status": "success",
+ "error": None,
+ }
+ except Exception as e:
+ if "stage_status" in metadata:
+ metadata["stage_status"]["embedding"] = {
+ "status": "failed",
+ "error": sanitize_error(e),
+ }
+ raise
set_stage(job, "indexing complete")
@@ -183,6 +234,15 @@ def analyze_image(media_id: int, clear_model_failures: bool = False):
db.commit()
invalidate_query_cache()
+ if runtime.applied_mode == "disabled":
+ logger.info("Metadata-only processing complete for media %s", media_id)
+ return {
+ "media_id": media_id,
+ "status": "success",
+ "mode": "disabled",
+ "metadata": metadata,
+ }
+
# near-duplicate detection
try:
from find_api.services.duplicate_service import (
@@ -254,6 +314,8 @@ def analyze_image(media_id: int, clear_model_failures: bool = False):
raise
finally:
+ if runtime_tokens is not None:
+ reset_runtime(runtime_tokens)
db.close()
@@ -262,13 +324,26 @@ def cluster_images():
Background job to cluster all indexed images
"""
- from find_api.ml.clusterer import get_image_clusterer
- from find_api.models.cluster import Cluster
- from find_api.core.config import settings
-
db = SessionLocal()
+ runtime_tokens = None
try:
+ runtime, runtime_tokens = _begin_worker_runtime(db)
+ if runtime.applied_mode == "unavailable":
+ raise RuntimeUnavailableError(runtime)
+ if runtime.applied_mode == "disabled":
+ logger.info("Skipping image clustering because AI is disabled")
+ return {
+ "status": "skipped",
+ "n_clusters": 0,
+ "message": "AI is disabled; clustering was not run",
+ }
+
+ import numpy as np
+
+ from find_api.ml.clusterer import get_image_clusterer
+ from find_api.models.cluster import Cluster
+
logger.info("Starting clustering job...")
# Step 1: Read data — no DB mutations yet.
@@ -377,6 +452,8 @@ def cluster_images():
raise
finally:
+ if runtime_tokens is not None:
+ reset_runtime(runtime_tokens)
clear_clustering_job_state()
db.close()
@@ -453,13 +530,27 @@ def cluster_faces():
4. Create a Person row for each group
5. Link each face to its Person group
"""
- from find_api.ml.clusterer import get_image_clusterer
- from find_api.models.face import Face
- from find_api.models.person import Person
-
db = SessionLocal()
+ runtime_tokens = None
try:
+ runtime, runtime_tokens = _begin_worker_runtime(db)
+ if runtime.applied_mode == "unavailable":
+ raise RuntimeUnavailableError(runtime)
+ if runtime.applied_mode != "full":
+ logger.info("Skipping face clustering in %s mode", runtime.applied_mode)
+ return {
+ "status": "skipped",
+ "n_persons": 0,
+ "message": "Face clustering requires the full AI runtime",
+ }
+
+ import numpy as np
+
+ from find_api.ml.clusterer import get_image_clusterer
+ from find_api.models.face import Face
+ from find_api.models.person import Person
+
logger.info("Starting face clustering job...")
# Step 1: Load all faces that have embeddings.
@@ -609,4 +700,6 @@ def cluster_faces():
raise
finally:
+ if runtime_tokens is not None:
+ reset_runtime(runtime_tokens)
db.close()
diff --git a/backend/src/find_api/workers/processors.py b/backend/src/find_api/workers/processors.py
index 31d501a0..efaa7528 100644
--- a/backend/src/find_api/workers/processors.py
+++ b/backend/src/find_api/workers/processors.py
@@ -9,8 +9,8 @@
import numpy as np
from PIL import Image
-from find_api.core.config import settings
from find_api.core.model_manager import ModelUnavailableError
+from find_api.core.runtime_profile import current_ml_mode
from find_api.ml.mock_embedder import get_mock_embedder
from find_api.utils.errors import sanitize_error
@@ -76,7 +76,8 @@ def extract_image_metadata(
"""
Run all ML models to extract metadata from image
"""
- if settings.ML_MODE.lower() == "mock":
+ mode = current_ml_mode()
+ if mode == "mock":
if on_stage:
on_stage("generating mock metadata")
logger.info("Using mock image metadata extractor")
@@ -93,6 +94,8 @@ def extract_image_metadata(
"embedding": {"status": "pending", "error": None},
},
}
+ if mode != "full":
+ raise RuntimeError(f"AI metadata extraction is unavailable in mode '{mode}'.")
metadata = {
"stage_status": {
@@ -193,9 +196,12 @@ def generate_hybrid_embedding(
them as a deterministic non-zero vector that would introduce a
systematic bias across all images lacking that signal.
"""
- if settings.ML_MODE.lower() == "mock":
+ mode = current_ml_mode()
+ if mode == "mock":
logger.info("Using mock embedding generator")
return get_mock_embedder().embed_metadata(image, metadata)
+ if mode != "full":
+ raise RuntimeError(f"AI embedding generation is unavailable in mode '{mode}'.")
try:
logger.info("Generating CLIP embedding...")
@@ -327,8 +333,8 @@ def detect_and_store_faces(image: Image.Image, media_id: int, db) -> int:
# Mock mode - skip face detection entirely
# This keeps light/mock mode working without downloading face models
- if settings.ML_MODE.lower() == "mock":
- logger.info("Mock mode: skipping face detection for media %s", media_id)
+ if current_ml_mode() != "full":
+ logger.info("Non-full AI mode: skipping face detection for media %s", media_id)
return 0
# Real mode - run actual face detection
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index f77407f7..51aa6b1f 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -121,6 +121,11 @@ def test_setup_creates_admin(self, client):
assert "token" in data
assert "expires_at" in data
+ def test_setup_sets_httponly_session_cookie(self, client):
+ _setup_admin(client)
+ cookie = client.cookies.get("find_session")
+ assert cookie
+
def test_setup_only_once(self, client):
_setup_admin(client)
resp = client.post(
@@ -203,6 +208,12 @@ def test_token_works_on_me_endpoint(self, client):
assert resp.status_code == 200
assert resp.json()["user"]["username"] == ADMIN_USERNAME
+ def test_httponly_cookie_works_on_me_endpoint(self, client):
+ _setup_admin(client)
+ resp = client.get("/api/auth/me")
+ assert resp.status_code == 200
+ assert resp.json()["user"]["username"] == ADMIN_USERNAME
+
def test_me_returns_local_mode_without_token(self, client):
resp = client.get("/api/auth/me")
assert resp.status_code == 200
@@ -222,6 +233,82 @@ def test_logout_invalidates_token(self, client):
# In shared mode the token no longer resolves → 401
assert me.status_code == 401
+ def test_logout_clears_cookie_session(self, client):
+ _setup_admin(client)
+ assert client.get("/api/auth/me").status_code == 200
+ assert client.post("/api/auth/logout").status_code == 200
+ assert client.get("/api/auth/me").status_code == 401
+
+
+class TestAccountSettings:
+ def test_auth_status_reports_local_then_shared(self, client):
+ local = client.get("/api/auth/status")
+ assert local.json() == {"mode": "local", "setup_available": True}
+ _setup_admin(client)
+ shared = client.get("/api/auth/status")
+ assert shared.json() == {"mode": "shared", "setup_available": False}
+
+ def test_profile_update_changes_current_account(self, client):
+ _setup_admin(client)
+ response = client.patch(
+ "/api/auth/profile",
+ json={"username": "owner", "display_name": "Find Owner"},
+ )
+ assert response.status_code == 200
+ assert response.json()["user"]["username"] == "owner"
+ assert response.json()["user"]["display_name"] == "Find Owner"
+
+ def test_profile_rejects_duplicate_username(self, client, db):
+ _setup_admin(client)
+ from find_api.core.auth import hash_password
+ from find_api.models.user import User
+
+ db.add(
+ User(
+ username="taken",
+ display_name="Taken",
+ password_hash=hash_password("anotherpass"),
+ role="member",
+ )
+ )
+ db.commit()
+ response = client.patch("/api/auth/profile", json={"username": "taken"})
+ assert response.status_code == 409
+
+ def test_password_change_rotates_sessions(self, client):
+ data = _setup_admin(client)
+ old_token = data["token"]
+ second = _login(client).json()["token"]
+ response = client.post(
+ "/api/auth/password",
+ json={
+ "current_password": ADMIN_PASSWORD,
+ "new_password": "new-secure-password",
+ },
+ )
+ assert response.status_code == 200
+ assert (
+ client.get("/api/auth/me", headers=_auth_header(old_token)).status_code
+ == 401
+ )
+ assert (
+ client.get("/api/auth/me", headers=_auth_header(second)).status_code == 401
+ )
+ assert client.get("/api/auth/me").status_code == 200
+ assert _login(client, password="new-secure-password").status_code == 200
+
+ def test_sessions_can_be_listed_and_revoked(self, client):
+ _setup_admin(client)
+ sessions = client.get("/api/auth/sessions")
+ assert sessions.status_code == 200
+ assert len(sessions.json()["sessions"]) == 1
+ current = sessions.json()["sessions"][0]
+ assert current["current"] is True
+ revoked = client.delete(f"/api/auth/sessions/{current['id']}")
+ assert revoked.status_code == 200
+ assert revoked.json()["current"] is True
+ assert client.get("/api/auth/me").status_code == 401
+
# ---------------------------------------------------------------------------
# Invite tokens
@@ -412,6 +499,7 @@ def test_password_is_stored_as_bcrypt_hash(self, client, db):
def test_unauthenticated_cannot_list_join_requests(self, client):
_setup_admin(client)
+ client.cookies.clear()
resp = client.get("/api/auth/join-requests")
assert resp.status_code in (401, 403)
diff --git a/backend/tests/test_cluster_images_regression.py b/backend/tests/test_cluster_images_regression.py
index 953bf19b..75587397 100644
--- a/backend/tests/test_cluster_images_regression.py
+++ b/backend/tests/test_cluster_images_regression.py
@@ -91,6 +91,10 @@ def _run(mock_db: MagicMock, mock_clusterer: MagicMock):
with (
patch("find_api.workers.jobs.SessionLocal", return_value=mock_db),
+ patch(
+ "find_api.workers.jobs._begin_worker_runtime",
+ return_value=(SimpleNamespace(applied_mode="full"), None),
+ ),
patch("find_api.workers.jobs.clear_clustering_job_state"),
patch("find_api.workers.jobs.get_model_manager"),
patch(
diff --git a/backend/tests/test_clustering_edge_cases.py b/backend/tests/test_clustering_edge_cases.py
index 39d38ffe..ca2b4fa5 100644
--- a/backend/tests/test_clustering_edge_cases.py
+++ b/backend/tests/test_clustering_edge_cases.py
@@ -11,6 +11,14 @@
from find_api.workers.jobs import cluster_images
+def _full_runtime():
+ """Unit tests mock inference, so explicitly model a CPU/full artifact."""
+ return patch(
+ "find_api.workers.jobs._begin_worker_runtime",
+ return_value=(SimpleNamespace(applied_mode="full"), None),
+ )
+
+
def _make_media_row(media_id: int = 1, vector: list[float] | None = None):
"""Return a minimal object matching the columns `cluster_images` reads."""
return SimpleNamespace(
@@ -30,6 +38,7 @@ def test_zero_indexed_images_returns_early(self):
with (
patch("find_api.workers.jobs.SessionLocal", return_value=mock_db),
+ _full_runtime(),
patch("find_api.workers.jobs.clear_clustering_job_state"),
patch("find_api.ml.clusterer.get_image_clusterer") as mock_clusterer,
):
@@ -55,6 +64,7 @@ def test_fewer_than_min_cluster_size_skips_clusterer(self):
with (
patch("find_api.workers.jobs.SessionLocal", return_value=mock_db),
+ _full_runtime(),
patch("find_api.workers.jobs.clear_clustering_job_state"),
patch("find_api.ml.clusterer.get_image_clusterer") as mock_clusterer,
):
@@ -83,6 +93,7 @@ def test_no_stable_clusters_returns_empty_cluster_ids(self):
with (
patch("find_api.workers.jobs.SessionLocal", return_value=mock_db),
+ _full_runtime(),
patch("find_api.workers.jobs.clear_clustering_job_state"),
patch(
"find_api.ml.clusterer.get_image_clusterer",
@@ -128,6 +139,7 @@ def fake_add(cluster):
with (
patch("find_api.workers.jobs.SessionLocal", return_value=mock_db),
+ _full_runtime(),
patch("find_api.workers.jobs.clear_clustering_job_state"),
patch(
"find_api.ml.clusterer.get_image_clusterer",
diff --git a/backend/tests/test_exif.py b/backend/tests/test_exif.py
new file mode 100644
index 00000000..3da114cd
--- /dev/null
+++ b/backend/tests/test_exif.py
@@ -0,0 +1,72 @@
+"""Focused tests for privacy-preserving EXIF helpers."""
+
+from find_api.utils.exif import extract_gps_coordinates
+
+
+class _Exif(dict):
+ def __init__(self, gps):
+ super().__init__({0x8825: 1})
+ self._gps = gps
+
+ def get_ifd(self, tag):
+ assert tag == 0x8825
+ return self._gps
+
+
+class _Image:
+ def __init__(self, gps):
+ self._exif = _Exif(gps)
+
+ def getexif(self):
+ return self._exif
+
+
+def test_extract_gps_coordinates_converts_dms_and_direction():
+ image = _Image(
+ {
+ 1: "N",
+ 2: (22, 34, 21.36),
+ 3: "E",
+ 4: (88, 21, 50.04),
+ }
+ )
+
+ assert extract_gps_coordinates(image) == (22.5726, 88.3639)
+
+
+def test_extract_gps_coordinates_applies_south_and_west_signs():
+ image = _Image(
+ {
+ 1: "S",
+ 2: (33, 52, 7.68),
+ 3: "W",
+ 4: (151, 12, 33.48),
+ }
+ )
+
+ assert extract_gps_coordinates(image) == (-33.8688, -151.2093)
+
+
+def test_extract_gps_coordinates_rejects_partial_coordinates():
+ image = _Image({1: "N", 2: (22, 34, 21.36)})
+
+ assert extract_gps_coordinates(image) is None
+
+
+def test_extract_gps_coordinates_rejects_out_of_range_values():
+ image = _Image(
+ {
+ 1: "N",
+ 2: (95, 0, 0),
+ 3: "E",
+ 4: (88, 21, 50.04),
+ }
+ )
+
+ assert extract_gps_coordinates(image) is None
+
+
+def test_extract_gps_coordinates_tolerates_malformed_exif():
+ image = _Image({1: "N", 2: "not-a-dms-tuple", 3: "E", 4: (1, 2, 3)})
+
+ assert extract_gps_coordinates(image) is None
diff --git a/backend/tests/test_gallery.py b/backend/tests/test_gallery.py
index 4f462478..0fe093bf 100644
--- a/backend/tests/test_gallery.py
+++ b/backend/tests/test_gallery.py
@@ -140,6 +140,20 @@ def test_thumbnail_redirect_falls_back_to_original(self, client, db):
assert response.status_code == 307
assert response.headers["location"] == "http://fake/images/test/legacy.jpg"
+ def test_original_route_redirects_to_scoped_object(self, client, db):
+ media = _seed(db, filename="viewer.jpg", status="indexed")
+
+ with patch(
+ "find_api.routers.gallery.get_file_url",
+ side_effect=lambda key: f"http://fake/{key}",
+ ):
+ response = client.get(
+ f"/api/image/{media.id}/original", follow_redirects=False
+ )
+
+ assert response.status_code == 307
+ assert response.headers["location"] == "http://fake/images/test/viewer.jpg"
+
def test_backfill_missing_thumbnails_enqueues_thumbnail_only_jobs(self, client, db):
existing = _seed(db, filename="existing.jpg", status="indexed")
missing_a = _seed(db, filename="missing-a.jpg", status="indexed")
@@ -345,11 +359,15 @@ def test_hidden_media_not_available_on_public_media_routes(self, client, db):
thumbnail_response = client.get(
f"/api/image/{hidden.id}/thumbnail", follow_redirects=False
)
+ original_response = client.get(
+ f"/api/image/{hidden.id}/original", follow_redirects=False
+ )
like_response = client.post(f"/api/image/{hidden.id}/like")
reprocess_response = client.post(f"/api/image/{hidden.id}/reprocess")
assert detail_response.status_code == 404
assert thumbnail_response.status_code == 404
+ assert original_response.status_code == 404
assert like_response.status_code == 404
assert reprocess_response.status_code == 404
diff --git a/backend/tests/test_hybrid_embedding.py b/backend/tests/test_hybrid_embedding.py
index 13b79e7f..d974c180 100644
--- a/backend/tests/test_hybrid_embedding.py
+++ b/backend/tests/test_hybrid_embedding.py
@@ -93,10 +93,9 @@ def _run(
fake_clip_module.get_clip_embedder.return_value = embedder
with (
- patch("find_api.workers.processors.settings") as mock_settings,
+ patch("find_api.workers.processors.current_ml_mode", return_value="full"),
patch.dict(sys.modules, {"find_api.ml.clip_embedder": fake_clip_module}),
):
- mock_settings.ML_MODE = "full"
result = generate_hybrid_embedding(fake_image, metadata)
return np.array(result, dtype=np.float32), embedder
diff --git a/backend/tests/test_map.py b/backend/tests/test_map.py
new file mode 100644
index 00000000..06b770dc
--- /dev/null
+++ b/backend/tests/test_map.py
@@ -0,0 +1,160 @@
+"""Privacy and scoping tests for the opt-in local map."""
+
+from datetime import datetime, timezone
+from typing import Optional
+
+from find_api.models.app_setting import AppSetting
+from find_api.models.media import Media
+from find_api.models.user import User
+from find_api.routers import map as map_router
+
+
+def _media(
+ media_id: int,
+ *,
+ latitude: Optional[float] = 22.5726,
+ longitude: Optional[float] = 88.3639,
+ liked: bool = False,
+ hidden: bool = False,
+ archived: bool = False,
+ deleted: bool = False,
+ vault_state: str = "visible",
+ uploader_user_id: Optional[int] = None,
+) -> Media:
+ return Media(
+ id=media_id,
+ file_hash=f"{media_id:064x}",
+ minio_key=f"images/{media_id}.jpg",
+ filename=f"photo-{media_id}.jpg",
+ status="indexed",
+ created_at=datetime(2026, 7, media_id, tzinfo=timezone.utc),
+ width=1600,
+ height=900,
+ latitude=latitude,
+ longitude=longitude,
+ liked=liked,
+ is_hidden=hidden,
+ is_archived=archived,
+ deleted_at=datetime.now(timezone.utc) if deleted else None,
+ vault_state=vault_state,
+ uploader_user_id=uploader_user_id,
+ )
+
+
+def _enable_map(db) -> None:
+ db.add(AppSetting(key="map_enabled", value="true"))
+ db.commit()
+
+
+def test_map_is_disabled_by_default(client, db):
+ db.add(AppSetting(key="map_enabled", value="false"))
+ db.add(_media(1))
+ db.commit()
+
+ response = client.get("/api/map/markers")
+
+ assert response.status_code == 200
+ assert response.json() == {"enabled": False, "markers": [], "total": 0}
+
+
+def test_map_returns_only_visible_non_deleted_assets(client, db):
+ _enable_map(db)
+ db.add_all(
+ [
+ _media(1),
+ _media(2, hidden=True),
+ _media(3, archived=True),
+ _media(4, deleted=True),
+ _media(5, latitude=None, longitude=None),
+ _media(6, vault_state="encrypted"),
+ ]
+ )
+ db.commit()
+
+ response = client.get("/api/map/markers")
+
+ assert response.status_code == 200
+ body = response.json()
+ assert body["enabled"] is True
+ assert [marker["id"] for marker in body["markers"]] == [1]
+ assert body["markers"][0]["thumbnail_url"] == "/api/image/1/thumbnail"
+ assert body["markers"][0]["ratio"] == 1.7778
+ assert body["markers"][0]["liked"] is False
+
+
+def test_map_filters_favorites_bbox_and_optional_archive(client, db):
+ _enable_map(db)
+ db.add_all(
+ [
+ _media(1, latitude=22.57, longitude=88.36, liked=True),
+ _media(2, latitude=51.50, longitude=-0.12, liked=True),
+ _media(
+ 3,
+ latitude=22.58,
+ longitude=88.40,
+ liked=True,
+ archived=True,
+ ),
+ _media(4, latitude=22.59, longitude=88.41, liked=False),
+ ]
+ )
+ db.commit()
+
+ response = client.get(
+ "/api/map/markers",
+ params={
+ "liked": "true",
+ "include_archived": "true",
+ "west": 88,
+ "east": 89,
+ "south": 22,
+ "north": 23,
+ },
+ )
+
+ assert response.status_code == 200
+ assert {marker["id"] for marker in response.json()["markers"]} == {1, 3}
+
+
+def test_map_bbox_wraps_across_the_antimeridian(client, db):
+ _enable_map(db)
+ db.add_all(
+ [
+ _media(1, latitude=10, longitude=179.5),
+ _media(2, latitude=10, longitude=-179.5),
+ _media(3, latitude=10, longitude=0),
+ ]
+ )
+ db.commit()
+
+ response = client.get(
+ "/api/map/markers",
+ params={"west": 170, "east": -170, "south": 0, "north": 20},
+ )
+
+ assert response.status_code == 200
+ assert {marker["id"] for marker in response.json()["markers"]} == {1, 2}
+
+
+def test_marker_query_is_scoped_to_the_signed_in_member(db):
+ alice = User(id=10, username="alice", role="member", password_hash="hash")
+ db.add_all(
+ [
+ _media(1, uploader_user_id=10),
+ _media(2, uploader_user_id=11),
+ ]
+ )
+ db.commit()
+
+ rows = map_router._marker_query(
+ db,
+ alice,
+ include_archived=False,
+ liked=None,
+ west=None,
+ south=None,
+ east=None,
+ north=None,
+ ).all()
+
+ assert [media.id for media in rows] == [1]
diff --git a/backend/tests/test_model_manager.py b/backend/tests/test_model_manager.py
index 6bb447e2..30e7dc2d 100644
--- a/backend/tests/test_model_manager.py
+++ b/backend/tests/test_model_manager.py
@@ -167,6 +167,7 @@ def extract_text_and_boxes(self, _image):
)
monkeypatch.setitem(sys.modules, "find_api.ml.captioner", captioner_module)
monkeypatch.setitem(sys.modules, "find_api.ml.ocr", ocr_module)
+ monkeypatch.setattr("find_api.workers.processors.current_ml_mode", lambda: "full")
metadata = extract_image_metadata(Image.new("RGB", (8, 8)))
diff --git a/backend/tests/test_runtime_profile.py b/backend/tests/test_runtime_profile.py
new file mode 100644
index 00000000..2f4e94cd
--- /dev/null
+++ b/backend/tests/test_runtime_profile.py
@@ -0,0 +1,83 @@
+"""Build-capability and runtime-resolution tests for modular AI artifacts."""
+
+from unittest.mock import patch
+
+from find_api.core.runtime_profile import (
+ RuntimePreferences,
+ bind_runtime,
+ current_accel_mode,
+ current_map_enabled,
+ current_ml_mode,
+ installed_features,
+ reset_runtime,
+ resolve_runtime,
+ supported_modes,
+)
+
+
+def _preferences(
+ *, ai_enabled=True, accel_mode="auto", map_enabled=False, ml_mode="full"
+):
+ return RuntimePreferences(
+ accel_mode=accel_mode,
+ ai_enabled=ai_enabled,
+ map_enabled=map_enabled,
+ ml_mode=ml_mode,
+ )
+
+
+def test_artifacts_expose_only_installed_modes():
+ assert supported_modes("no-ai") == ("disabled",)
+ assert supported_modes("mock") == ("disabled", "mock")
+ assert supported_modes("cpu") == ("disabled", "mock", "full")
+ assert supported_modes("nvidia") == ("disabled", "mock", "full")
+
+
+def test_no_ai_artifact_is_metadata_only():
+ resolution = resolve_runtime(
+ _preferences(), build_profile="no-ai", configured_mode="full"
+ )
+ assert resolution.applied_mode == "unavailable"
+ assert resolution.restart_required is True
+ assert installed_features("no-ai") == ("thumbnails", "dimensions", "exif")
+
+
+def test_kill_switch_disables_even_installed_full_runtime():
+ resolution = resolve_runtime(
+ _preferences(ai_enabled=False),
+ build_profile="nvidia",
+ configured_mode="full",
+ )
+ assert resolution.applied_mode == "disabled"
+ assert resolution.restart_required is False
+
+
+def test_remote_mode_is_unavailable_without_local_fallback():
+ resolution = resolve_runtime(
+ _preferences(), build_profile="nvidia", configured_mode="remote"
+ )
+ assert resolution.applied_mode == "unavailable"
+ assert resolution.restart_required is False
+ assert "will not fall back" in (resolution.unavailable_reason or "").lower()
+
+
+def test_bound_job_preferences_drive_all_runtime_seams_and_reset():
+ resolution = resolve_runtime(
+ _preferences(accel_mode="cpu", map_enabled=True),
+ build_profile="cpu",
+ configured_mode="mock",
+ )
+ with (
+ patch("find_api.core.runtime_profile.settings.ML_MODE", "disabled"),
+ patch("find_api.core.runtime_profile.settings.ACCEL_MODE", "auto"),
+ patch("find_api.core.runtime_profile.settings.MAP_ENABLED", False),
+ ):
+ tokens = bind_runtime(resolution)
+ try:
+ assert current_ml_mode() == "mock"
+ assert current_accel_mode() == "cpu"
+ assert current_map_enabled() is True
+ finally:
+ reset_runtime(tokens)
+
+ assert current_map_enabled() is False
diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py
index 4c2cade1..8e3f604a 100644
--- a/backend/tests/test_search.py
+++ b/backend/tests/test_search.py
@@ -1,6 +1,7 @@
"""Tests for GET /api/search response shape and pagination behavior."""
from datetime import datetime, timezone
+from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -12,8 +13,16 @@
@pytest.fixture(autouse=True)
-def clear_cache_between_tests():
+def clear_cache_between_tests(monkeypatch):
clear_query_cache()
+ monkeypatch.setattr(
+ "find_api.routers.search.resolve_runtime",
+ lambda *_args, **_kwargs: SimpleNamespace(
+ applied_mode="mock", configured_accel_mode="cpu"
+ ),
+ )
+ monkeypatch.setattr("find_api.routers.search.bind_runtime", lambda _runtime: None)
+ monkeypatch.setattr("find_api.routers.search.reset_runtime", lambda _tokens: None)
def _fake_search_row(media_id: int = 1, similarity: float = 0.82) -> MagicMock:
@@ -94,6 +103,12 @@ def _override():
"find_api.ml.mock_embedder.get_mock_embedder",
return_value=mock_embedder,
),
+ patch(
+ "find_api.routers.search.resolve_runtime",
+ return_value=MagicMock(applied_mode="mock"),
+ ),
+ patch("find_api.routers.search.bind_runtime", return_value=None),
+ patch("find_api.routers.search.reset_runtime"),
):
response = client.get(
"/api/search", params={"q": "sunset", **(params or {})}
@@ -453,6 +468,12 @@ def _override():
"find_api.ml.mock_embedder.get_mock_embedder",
return_value=mock_embedder,
),
+ patch(
+ "find_api.routers.search.resolve_runtime",
+ return_value=MagicMock(applied_mode="mock"),
+ ),
+ patch("find_api.routers.search.bind_runtime", return_value=None),
+ patch("find_api.routers.search.reset_runtime"),
):
return client.get(
"/api/search", params={"q": "sunset", "debug": str(debug).lower()}
diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py
index f00baf24..348e16bc 100644
--- a/backend/tests/test_settings.py
+++ b/backend/tests/test_settings.py
@@ -4,12 +4,20 @@
hardware report reflects the persisted accel-mode preference once set.
"""
+from unittest.mock import patch
+
+from find_api.core.config import settings
+
class TestSettingsLocalMode:
def test_get_defaults_to_env(self, client):
body = client.get("/api/settings").json()
# Env default is "auto" (no row persisted yet).
assert body["accel_mode"] == "auto"
+ assert body["ai_enabled"] is True
+ assert body["map_enabled"] is False
+ assert body["ml_mode"] == settings.ML_MODE
+ assert "disabled" in body["supported_ml_modes"]
def test_put_persists_and_reads_back(self, client):
put = client.put("/api/settings", json={"accel_mode": "cpu"})
@@ -48,3 +56,55 @@ def test_invalid_mode_rejected(self, client):
client.put("/api/settings", json={"accel_mode": "turbo"}).status_code == 422
)
assert client.get("/api/settings").json()["accel_mode"] == "auto"
+
+ def test_ai_and_map_preferences_persist_together(self, client):
+ response = client.put(
+ "/api/settings",
+ json={"ai_enabled": False, "map_enabled": True},
+ )
+ assert response.status_code == 200
+ body = response.json()
+ assert body["accel_mode"] == "auto"
+ assert body["ai_enabled"] is False
+ assert body["map_enabled"] is True
+ assert body["ml_mode"] == settings.ML_MODE
+ assert "disabled" in body["supported_ml_modes"]
+ assert client.get("/api/config").json()["ai_enabled"] is False
+ assert client.get("/api/config").json()["map_enabled"] is True
+
+ def test_installed_ai_mode_can_be_changed_from_settings(self, client):
+ with patch("find_api.core.runtime_profile.settings.FIND_BUILD_PROFILE", "cpu"):
+ response = client.put("/api/settings", json={"ml_mode": "full"})
+ assert response.status_code == 200
+ assert response.json()["ml_mode"] == "full"
+ assert client.get("/api/config").json()["configured_ml_mode"] == "full"
+
+ def test_uninstalled_ai_mode_is_rejected(self, client):
+ with patch(
+ "find_api.core.runtime_profile.settings.FIND_BUILD_PROFILE", "no-ai"
+ ):
+ response = client.put("/api/settings", json={"ml_mode": "full"})
+ assert response.status_code == 422
+
+ def test_runtime_endpoint_reports_worker_applied_state(self, client):
+ worker_process = {
+ "process": "worker",
+ "updated_at": 1,
+ "runtime": {"applied_mode": "mock", "preferences_source": "database"},
+ }
+ with (
+ patch(
+ "find_api.routers.config.get_worker_process_status",
+ return_value=worker_process,
+ ),
+ patch(
+ "find_api.routers.config.get_worker_runtime_status",
+ return_value=worker_process["runtime"],
+ ),
+ ):
+ body = client.get("/api/config/runtime").json()
+
+ assert body["build_profile"]
+ assert body["installed_features"]
+ assert body["worker"]["applied"]["applied_mode"] == "mock"
+ assert body["worker"]["health"]["state"] in {"healthy", "stale"}
diff --git a/backend/tests/test_status.py b/backend/tests/test_status.py
index 280b0a10..a13dba05 100644
--- a/backend/tests/test_status.py
+++ b/backend/tests/test_status.py
@@ -128,6 +128,8 @@ def test_loaded_models_endpoint(client):
assert "mock_test_model" in body["loaded_models"]
assert body["processes"]["api"]["loaded_models"] == ["mock_test_model"]
assert "ttl_seconds" in body
+ assert body["worker_runtime"] is None
+ assert body["worker_health"]["state"] == "unavailable"
def test_loaded_models_endpoint_includes_worker_snapshot(client):
@@ -170,3 +172,4 @@ def test_loaded_models_endpoint_includes_worker_snapshot(client):
body["processes"]["worker"]["failed_models"]["florence-2"]["error"]
== "load failed"
)
+ assert body["worker_health"]["state"] == "stale"
diff --git a/backend/tests/test_thumbnails.py b/backend/tests/test_thumbnails.py
index 206c0230..6e3fa6e5 100644
--- a/backend/tests/test_thumbnails.py
+++ b/backend/tests/test_thumbnails.py
@@ -1,5 +1,6 @@
import io
import hashlib
+from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from PIL import Image
@@ -87,6 +88,10 @@ def test_analyze_image_backfills_missing_thumbnail(db):
with (
patch("find_api.workers.jobs.SessionLocal", return_value=db),
+ patch(
+ "find_api.workers.jobs._begin_worker_runtime",
+ return_value=(SimpleNamespace(applied_mode="full"), None),
+ ),
patch("find_api.workers.jobs.get_current_job", return_value=None),
patch("find_api.workers.jobs.get_file", return_value=data),
patch(
@@ -136,6 +141,10 @@ def test_analyze_image_keeps_existing_thumbnail(db):
with (
patch("find_api.workers.jobs.SessionLocal", return_value=db),
+ patch(
+ "find_api.workers.jobs._begin_worker_runtime",
+ return_value=(SimpleNamespace(applied_mode="full"), None),
+ ),
patch("find_api.workers.jobs.get_current_job", return_value=None),
patch("find_api.workers.jobs.get_file", return_value=data),
patch("find_api.workers.jobs.upload_thumbnail") as upload_thumb,
@@ -157,6 +166,44 @@ def test_analyze_image_keeps_existing_thumbnail(db):
upload_thumb.assert_not_called()
+def test_analyze_image_metadata_only_mode_never_imports_ai_pipeline(db):
+ data = _image_bytes(size=(64, 48))
+ file_hash = hashlib.sha256(data).hexdigest()
+ media = Media(
+ file_hash=file_hash,
+ minio_key="images/ab/metadata-only.png",
+ thumbnail_key="thumbnails/ab/existing.webp",
+ filename="metadata-only.png",
+ content_type="image/png",
+ file_size=len(data),
+ status="pending",
+ )
+ db.add(media)
+ db.commit()
+ db.refresh(media)
+ media_id = media.id
+
+ with (
+ patch("find_api.workers.jobs.SessionLocal", return_value=db),
+ patch("find_api.workers.jobs.get_current_job", return_value=None),
+ patch("find_api.workers.jobs.get_file", return_value=data),
+ patch("find_api.core.runtime_profile.settings.FIND_BUILD_PROFILE", "no-ai"),
+ patch("find_api.core.runtime_profile.settings.ML_MODE", "disabled"),
+ patch("find_api.core.runtime_profile.settings.AI_ENABLED", False),
+ patch("find_api.workers.jobs.enqueue_clustering_job") as enqueue_cluster,
+ ):
+ result = analyze_image(media_id)
+
+ updated = db.query(Media).filter(Media.id == media_id).one()
+ assert result["mode"] == "disabled"
+ assert updated.status == "indexed"
+ assert updated.vector is None
+ assert updated.metadata_json["ai_disabled"] is True
+ assert updated.width == 64
+ assert updated.height == 48
+ enqueue_cluster.assert_not_called()
+
+
def test_generate_thumbnail_for_media_backfills_without_full_analysis(db):
data = _image_bytes()
file_hash = hashlib.sha256(data).hexdigest()
diff --git a/backend/tests/test_vault.py b/backend/tests/test_vault.py
index d3337cce..96881181 100644
--- a/backend/tests/test_vault.py
+++ b/backend/tests/test_vault.py
@@ -6,7 +6,7 @@
import time
from datetime import datetime, timezone
from pathlib import Path
-from unittest.mock import patch
+from unittest.mock import Mock, call, patch
from cryptography.exceptions import InvalidTag
from PIL import Image
@@ -28,7 +28,9 @@ def get_valid_image_bytes():
return buf.getvalue()
-def seed_media(db, *, filename: str = "vault-image.png") -> Media:
+def seed_media(
+ db, *, filename: str = "vault-image.png", with_thumbnail: bool = False
+) -> Media:
"""Insert a Media row into the test database."""
media = Media(
file_hash=hashlib.sha256(filename.encode()).hexdigest(),
@@ -36,6 +38,11 @@ def seed_media(db, *, filename: str = "vault-image.png") -> Media:
filename=filename,
content_type="image/png",
file_size=len(get_valid_image_bytes()),
+ thumbnail_key=(f"thumbnails/test/{filename}.webp" if with_thumbnail else None),
+ thumbnail_content_type="image/webp" if with_thumbnail else None,
+ thumbnail_size=123 if with_thumbnail else None,
+ thumbnail_width=1 if with_thumbnail else None,
+ thumbnail_height=1 if with_thumbnail else None,
status="indexed",
width=1,
height=1,
@@ -231,6 +238,64 @@ def test_invalid_session_token_rejected(self, client, db):
assert response.status_code == 401
+ def test_hide_removes_plaintext_original_and_thumbnail(
+ self, client, db, vault_artifacts
+ ):
+ media = seed_media(db, filename="with-thumb.png", with_thumbnail=True)
+ token = unlock_vault(client, db)
+ delete_mock = Mock()
+
+ with (
+ patch(
+ "find_api.routers.vault.download_file_to_path",
+ side_effect=lambda _key, path: Path(path).write_bytes(
+ get_valid_image_bytes()
+ ),
+ ),
+ patch("find_api.routers.vault.delete_file", delete_mock),
+ ):
+ response = client.post(
+ "/api/vault/hide",
+ json={"media_id": media.id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ assert response.status_code == 200
+ encrypted_path = Path(
+ db.execute(
+ text(
+ "SELECT encrypted_path FROM vault_metadata "
+ "WHERE media_id = :media_id"
+ ),
+ {"media_id": media.id},
+ ).scalar_one()
+ )
+ vault_artifacts.append(encrypted_path)
+ assert delete_mock.call_args_list == [
+ call(media.minio_key),
+ call(media.thumbnail_key),
+ ]
+
+
+class TestVaultLock:
+ """Locking invalidates the in-memory key immediately."""
+
+ def test_lock_invalidates_session(self, client, db):
+ token = unlock_vault(client, db)
+
+ response = client.post(
+ "/api/vault/lock",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+ assert response.json() == {"status": "locked"}
+
+ response = client.get(
+ "/api/vault/list",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 401
+
class TestVaultStream:
"""Vault streaming endpoint behavior."""
@@ -340,6 +405,163 @@ def test_tampered_authenticated_metadata_rejected(
)
+class TestVaultThumbnail:
+ """Vault timeline previews are bounded and session protected."""
+
+ def test_thumbnail_happy_path(self, client, db, vault_artifacts):
+ media = seed_media(db, filename="thumbnail.png")
+ token = unlock_vault(client, db)
+ encrypted_path = hide_media(client, db, media=media, token=token)
+ vault_artifacts.append(encrypted_path)
+
+ response = client.get(
+ f"/api/vault/thumbnail/{media.id}",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("image/webp")
+ assert response.headers["cache-control"] == "private, no-store"
+ with Image.open(io.BytesIO(response.content)) as thumbnail:
+ assert thumbnail.width <= vault_router.VAULT_THUMBNAIL_MAX_SIZE[0]
+ assert thumbnail.height <= vault_router.VAULT_THUMBNAIL_MAX_SIZE[1]
+
+ def test_thumbnail_rejects_invalid_session(self, client, db):
+ media = seed_media(db, filename="locked-thumbnail.png")
+ prepare_vault_tables(db)
+
+ response = client.get(
+ f"/api/vault/thumbnail/{media.id}",
+ headers={"Authorization": "Bearer not-a-vault-session"},
+ )
+
+ assert response.status_code == 401
+
+
+class TestVaultRestore:
+ """Restore keeps the encrypted rollback source until storage and DB succeed."""
+
+ def test_restore_happy_path(self, client, db, vault_artifacts):
+ media = seed_media(db, filename="restore.png")
+ token = unlock_vault(client, db)
+ encrypted_path = hide_media(client, db, media=media, token=token)
+ vault_artifacts.append(encrypted_path)
+ uploaded: dict[str, object] = {}
+
+ def capture_upload(data, object_name, content_type):
+ uploaded.update(
+ data=data,
+ object_name=object_name,
+ content_type=content_type,
+ )
+ return object_name
+
+ thumbnail_metadata = {
+ "thumbnail_key": "thumbnails/restored.webp",
+ "thumbnail_content_type": "image/webp",
+ "thumbnail_size": 42,
+ "thumbnail_width": 1,
+ "thumbnail_height": 1,
+ }
+ with (
+ patch("find_api.routers.vault.upload_file", side_effect=capture_upload),
+ patch(
+ "find_api.routers.vault.upload_thumbnail",
+ return_value=thumbnail_metadata,
+ ),
+ ):
+ response = client.post(
+ "/api/vault/restore",
+ json={"media_id": media.id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "status": "restored",
+ "media_id": media.id,
+ "encrypted_blob_removed": True,
+ }
+ assert uploaded["data"] == get_valid_image_bytes()
+ assert uploaded["object_name"] == media.minio_key
+ assert uploaded["content_type"] == "image/png"
+ db.refresh(media)
+ assert media.is_hidden is False
+ assert media.vault_state == "visible"
+ assert media.hidden_at is None
+ assert media.encrypted_at is None
+ assert media.thumbnail_key == thumbnail_metadata["thumbnail_key"]
+ assert (
+ db.execute(
+ text("SELECT 1 FROM vault_metadata WHERE media_id = :media_id"),
+ {"media_id": media.id},
+ ).first()
+ is None
+ )
+ assert not encrypted_path.exists()
+
+ def test_storage_failure_keeps_encrypted_item_hidden(
+ self, client, db, vault_artifacts
+ ):
+ media = seed_media(db, filename="restore-storage-failure.png")
+ token = unlock_vault(client, db)
+ encrypted_path = hide_media(client, db, media=media, token=token)
+ vault_artifacts.append(encrypted_path)
+
+ with patch(
+ "find_api.routers.vault.upload_file", side_effect=RuntimeError("offline")
+ ):
+ response = client.post(
+ "/api/vault/restore",
+ json={"media_id": media.id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ assert response.status_code == 500
+ db.refresh(media)
+ assert media.is_hidden is True
+ assert media.vault_state == "hidden_encrypted"
+ assert encrypted_path.exists()
+ assert db.execute(
+ text("SELECT 1 FROM vault_metadata WHERE media_id = :media_id"),
+ {"media_id": media.id},
+ ).first()
+
+ def test_database_failure_restores_encrypted_blob_and_cleans_replacement(
+ self, client, db, vault_artifacts
+ ):
+ media = seed_media(db, filename="restore-db-failure.png")
+ token = unlock_vault(client, db)
+ encrypted_path = hide_media(client, db, media=media, token=token)
+ vault_artifacts.append(encrypted_path)
+
+ with (
+ patch("find_api.routers.vault.upload_file", return_value=media.minio_key),
+ patch("find_api.routers.vault.upload_thumbnail", return_value=None),
+ patch("find_api.routers.vault.delete_file") as delete_mock,
+ patch(
+ "find_api.routers.vault.Session.commit",
+ side_effect=RuntimeError("db offline"),
+ ),
+ ):
+ response = client.post(
+ "/api/vault/restore",
+ json={"media_id": media.id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ assert response.status_code == 500
+ db.refresh(media)
+ assert media.is_hidden is True
+ assert media.vault_state == "hidden_encrypted"
+ assert encrypted_path.exists()
+ assert db.execute(
+ text("SELECT 1 FROM vault_metadata WHERE media_id = :media_id"),
+ {"media_id": media.id},
+ ).first()
+ delete_mock.assert_called_once_with(media.minio_key)
+
+
class TestVaultGalleryIntegration:
"""Vault-hidden media should not appear in the public gallery."""
diff --git a/backend/uv.lock b/backend/uv.lock
index b371498c..b9bda734 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -2,15 +2,20 @@ version = 1
revision = 3
requires-python = "==3.12.*"
resolution-markers = [
- "platform_machine == 'arm64' and sys_platform == 'darwin'",
- "platform_machine != 'arm64' and platform_machine != 's390x' and sys_platform == 'darwin'",
- "platform_machine == 's390x' and sys_platform == 'darwin'",
- "platform_machine == 'aarch64' and sys_platform == 'linux'",
- "platform_machine != 's390x' and sys_platform == 'win32'",
- "platform_machine == 's390x' and sys_platform == 'win32'",
- "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
- "platform_machine == 's390x' and sys_platform != 'darwin' and sys_platform != 'win32'",
-]
+ "sys_platform == 'darwin' and extra != 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia'",
+ "platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia'",
+ "sys_platform == 'win32' and extra != 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia'",
+ "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra != 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')",
+ "sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra != 'extra-12-find-backend-nvidia'",
+ "platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra != 'extra-12-find-backend-nvidia'",
+ "sys_platform == 'win32' and extra == 'extra-12-find-backend-cpu' and extra != 'extra-12-find-backend-nvidia'",
+ "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra != 'extra-12-find-backend-nvidia') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-12-find-backend-cpu' and extra != 'extra-12-find-backend-nvidia')",
+ "extra != 'extra-12-find-backend-cpu' and extra != 'extra-12-find-backend-nvidia'",
+]
+conflicts = [[
+ { package = "find-backend", extra = "cpu" },
+ { package = "find-backend", extra = "nvidia" },
+]]
[[package]]
name = "aiofiles"
@@ -249,7 +254,7 @@ name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+ { name = "pycparser", marker = "implementation_name != 'PyPy' or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
@@ -312,7 +317,7 @@ name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
@@ -408,7 +413,7 @@ name = "cryptography"
version = "48.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy' or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
wheels = [
@@ -516,25 +521,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" },
]
-[[package]]
-name = "faiss-cpu"
-version = "1.13.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
- { name = "packaging" },
-]
-wheels = [
- { url = "https://files.pythonhosted.org/packages/07/c9/671f66f6b31ec48e5825d36435f0cb91189fa8bb6b50724029dbff4ca83c/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9064eb34f8f64438dd5b95c8f03a780b1a3f0b99c46eeacb1f0b5d15fc02dc1", size = 3452776, upload-time = "2025-12-24T10:27:01.419Z" },
- { url = "https://files.pythonhosted.org/packages/5a/4a/97150aa1582fb9c2bca95bd8fc37f27d3b470acec6f0a6833844b21e4b40/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_x86_64.whl", hash = "sha256:c8d097884521e1ecaea6467aeebbf1aa56ee4a36350b48b2ca6b39366565c317", size = 7896434, upload-time = "2025-12-24T10:27:03.592Z" },
- { url = "https://files.pythonhosted.org/packages/0b/d0/0940575f059591ca31b63a881058adb16a387020af1709dcb7669460115c/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ee330a284042c2480f2e90450a10378fd95655d62220159b1408f59ee83ebf1", size = 11485825, upload-time = "2025-12-24T10:27:05.681Z" },
- { url = "https://files.pythonhosted.org/packages/e7/e1/a5acac02aa593809f0123539afe7b4aff61d1db149e7093239888c9053e1/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab88ee287c25a119213153d033f7dd64c3ccec466ace267395872f554b648cd7", size = 23845772, upload-time = "2025-12-24T10:27:08.194Z" },
- { url = "https://files.pythonhosted.org/packages/9c/7b/49dcaf354834ec457e85ca769d50bc9b5f3003fab7c94a9dcf08cf742793/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:85511129b34f890d19c98b82a0cd5ffb27d89d1cec2ee41d2621ee9f9ef8cf3f", size = 13477567, upload-time = "2025-12-24T10:27:10.822Z" },
- { url = "https://files.pythonhosted.org/packages/f7/6b/12bb4037921c38bb2c0b4cfc213ca7e04bbbebbfea89b0b5746248ce446e/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b32eb4065bac352b52a9f5ae07223567fab0a976c7d05017c01c45a1c24264f", size = 25102239, upload-time = "2025-12-24T10:27:13.476Z" },
- { url = "https://files.pythonhosted.org/packages/87/ff/35ed875423200c17bdd594ce921abfc1812ddd21e09355290b9a94e170ab/faiss_cpu-1.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:b82c01d30430dd7b1fa442001b9099735d1a82f6bb72033acdc9206d5ac66a64", size = 18890300, upload-time = "2025-12-24T10:27:24.194Z" },
- { url = "https://files.pythonhosted.org/packages/c5/3a/bbdf5deaf6feb34b46b469c0a0acd40216c3d3c6ecf5aeb71d56b8a650e3/faiss_cpu-1.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2c4f696ae76e7c97cbc12311db83aaf1e7f4f7be06a3ffea7e5b0e8ec1fd805b", size = 8553157, upload-time = "2025-12-24T10:27:26.38Z" },
-]
-
[[package]]
name = "fastapi"
version = "0.135.4"
@@ -562,17 +548,15 @@ wheels = [
[[package]]
name = "find-backend"
-version = "1.0.0"
+version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "alembic" },
{ name = "argon2-cffi" },
{ name = "cryptography" },
- { name = "cython" },
{ name = "fastapi" },
{ name = "httpx" },
- { name = "imagehash" },
{ name = "minio" },
{ name = "numpy" },
{ name = "passlib", extra = ["bcrypt"] },
@@ -586,27 +570,46 @@ dependencies = [
{ name = "python-multipart" },
{ name = "redis" },
{ name = "rq" },
- { name = "scikit-learn" },
- { name = "scipy" },
{ name = "slowapi" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
-ml = [
+cpu = [
+ { name = "cython" },
+ { name = "einops" },
+ { name = "insightface" },
+ { name = "onnxruntime" },
+ { name = "open-clip-torch" },
+ { name = "opencv-python-headless" },
+ { name = "paddleocr" },
+ { name = "paddlepaddle" },
+ { name = "scikit-learn" },
+ { name = "timm" },
+ { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (platform_machine != 'aarch64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "transformers" },
+ { name = "ultralytics" },
+]
+mock = [
+ { name = "scikit-learn" },
+]
+nvidia = [
+ { name = "cython" },
{ name = "einops" },
- { name = "faiss-cpu" },
- { name = "hdbscan" },
{ name = "insightface" },
{ name = "onnxruntime-gpu" },
{ name = "open-clip-torch" },
{ name = "opencv-python-headless" },
{ name = "paddleocr" },
{ name = "paddlepaddle" },
+ { name = "scikit-learn" },
{ name = "timm" },
- { name = "torch" },
- { name = "torchvision" },
+ { name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" } },
+ { name = "torchvision", version = "0.23.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" } },
{ name = "transformers" },
{ name = "ultralytics" },
]
@@ -618,6 +621,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
+ { name = "scikit-learn" },
]
[package.metadata]
@@ -626,21 +630,26 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.17.2,<2" },
{ name = "argon2-cffi", specifier = ">=23.1.0" },
{ name = "cryptography", specifier = ">=48.0.1,<49" },
- { name = "cython", specifier = "<3" },
- { name = "einops", marker = "extra == 'ml'", specifier = ">=0.8.0" },
- { name = "faiss-cpu", marker = "extra == 'ml'", specifier = ">=1.13.2,<1.14" },
+ { name = "cython", marker = "extra == 'cpu'", specifier = "<3" },
+ { name = "cython", marker = "extra == 'nvidia'", specifier = "<3" },
+ { name = "einops", marker = "extra == 'cpu'", specifier = ">=0.8.0" },
+ { name = "einops", marker = "extra == 'nvidia'", specifier = ">=0.8.0" },
{ name = "fastapi", specifier = ">=0.135.1,<0.136" },
- { name = "hdbscan", marker = "extra == 'ml'", specifier = "==0.8.40" },
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
- { name = "imagehash", specifier = "==4.3.1" },
- { name = "insightface", marker = "extra == 'ml'", specifier = "==0.7.3" },
+ { name = "insightface", marker = "extra == 'cpu'", specifier = "==0.7.3" },
+ { name = "insightface", marker = "extra == 'nvidia'", specifier = "==0.7.3" },
{ name = "minio", specifier = "==7.2.3" },
{ name = "numpy", specifier = "==1.26.3" },
- { name = "onnxruntime-gpu", marker = "extra == 'ml'", specifier = "==1.17.0" },
- { name = "open-clip-torch", marker = "extra == 'ml'", specifier = ">=2.24.0" },
- { name = "opencv-python-headless", marker = "extra == 'ml'", specifier = "==4.9.0.80" },
- { name = "paddleocr", marker = "extra == 'ml'", specifier = ">=3.5.0,<4" },
- { name = "paddlepaddle", marker = "extra == 'ml'", specifier = ">=3.2.2,<3.3" },
+ { name = "onnxruntime", marker = "extra == 'cpu'", specifier = "==1.17.0" },
+ { name = "onnxruntime-gpu", marker = "extra == 'nvidia'", specifier = "==1.17.0" },
+ { name = "open-clip-torch", marker = "extra == 'cpu'", specifier = ">=2.24.0" },
+ { name = "open-clip-torch", marker = "extra == 'nvidia'", specifier = ">=2.24.0" },
+ { name = "opencv-python-headless", marker = "extra == 'cpu'", specifier = "==4.9.0.80" },
+ { name = "opencv-python-headless", marker = "extra == 'nvidia'", specifier = "==4.9.0.80" },
+ { name = "paddleocr", marker = "extra == 'cpu'", specifier = ">=3.5.0,<4" },
+ { name = "paddleocr", marker = "extra == 'nvidia'", specifier = ">=3.5.0,<4" },
+ { name = "paddlepaddle", marker = "extra == 'cpu'", specifier = ">=3.2.2,<3.3" },
+ { name = "paddlepaddle", marker = "extra == 'nvidia'", specifier = ">=3.2.2,<3.3" },
{ name = "passlib", extras = ["bcrypt"], specifier = "==1.7.4" },
{ name = "pgvector", specifier = "==0.2.4" },
{ name = "piexif", specifier = "==1.1.3" },
@@ -652,18 +661,24 @@ requires-dist = [
{ name = "python-multipart", specifier = ">=0.0.31,<0.1" },
{ name = "redis", specifier = "==5.0.1" },
{ name = "rq", specifier = ">=2.6.1,<3" },
- { name = "scikit-learn", specifier = "==1.5.0" },
- { name = "scipy", specifier = "==1.12.0" },
+ { name = "scikit-learn", marker = "extra == 'cpu'", specifier = "==1.5.0" },
+ { name = "scikit-learn", marker = "extra == 'mock'", specifier = "==1.5.0" },
+ { name = "scikit-learn", marker = "extra == 'nvidia'", specifier = "==1.5.0" },
{ name = "slowapi", specifier = ">=0.1.9" },
{ name = "sqlalchemy", specifier = ">=2.0.45,<2.1" },
- { name = "timm", marker = "extra == 'ml'", specifier = ">=0.9.16" },
- { name = "torch", marker = "extra == 'ml'", specifier = "==2.8.0", index = "https://download.pytorch.org/whl/cu128" },
- { name = "torchvision", marker = "extra == 'ml'", specifier = "==0.23.0", index = "https://download.pytorch.org/whl/cu128" },
- { name = "transformers", marker = "extra == 'ml'", specifier = ">=5.3.0,<6" },
- { name = "ultralytics", marker = "extra == 'ml'", specifier = ">=8.2.0" },
+ { name = "timm", marker = "extra == 'cpu'", specifier = ">=0.9.16" },
+ { name = "timm", marker = "extra == 'nvidia'", specifier = ">=0.9.16" },
+ { name = "torch", marker = "extra == 'cpu'", specifier = "==2.8.0", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "find-backend", extra = "cpu" } },
+ { name = "torch", marker = "extra == 'nvidia'", specifier = "==2.8.0", index = "https://download.pytorch.org/whl/cu128", conflict = { package = "find-backend", extra = "nvidia" } },
+ { name = "torchvision", marker = "extra == 'cpu'", specifier = "==0.23.0", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "find-backend", extra = "cpu" } },
+ { name = "torchvision", marker = "extra == 'nvidia'", specifier = "==0.23.0", index = "https://download.pytorch.org/whl/cu128", conflict = { package = "find-backend", extra = "nvidia" } },
+ { name = "transformers", marker = "extra == 'cpu'", specifier = ">=5.3.0,<6" },
+ { name = "transformers", marker = "extra == 'nvidia'", specifier = ">=5.3.0,<6" },
+ { name = "ultralytics", marker = "extra == 'cpu'", specifier = ">=8.2.0" },
+ { name = "ultralytics", marker = "extra == 'nvidia'", specifier = ">=8.2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.43.0,<0.44" },
]
-provides-extras = ["ml"]
+provides-extras = ["mock", "cpu", "nvidia"]
[package.metadata.requires-dev]
dev = [
@@ -672,6 +687,7 @@ dev = [
{ name = "pytest", specifier = "==9.0.3" },
{ name = "pytest-asyncio", specifier = "==0.23.3" },
{ name = "ruff", specifier = "==0.1.14" },
+ { name = "scikit-learn", specifier = "==1.5.0" },
]
[[package]]
@@ -738,7 +754,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
@@ -754,23 +772,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
-[[package]]
-name = "hdbscan"
-version = "0.8.40"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "joblib" },
- { name = "numpy" },
- { name = "scikit-learn" },
- { name = "scipy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c1/84/6b010387b795f774e1ec695df3c8660c15abd041783647d5e7e4076bfc6b/hdbscan-0.8.40.tar.gz", hash = "sha256:c9e383ff17beee0591075ff65d524bda5b5a35dfb01d218245a7ba30c8d48a17", size = 6904096, upload-time = "2024-11-18T16:14:05.384Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/33/ff/4739886abb990dc6feb7b02eafb38a7eaf090fffef6336e70a03d693f433/hdbscan-0.8.40-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:353eaa22e42bee69df095744dbb8b29360e516bd9dcb84580dceeeb755f004cc", size = 1497291, upload-time = "2024-11-18T16:16:54.731Z" },
- { url = "https://files.pythonhosted.org/packages/f0/0f/97a315772abf99b3c230e3d57f3fa426d163ce4d6070241d68a3f0241ea9/hdbscan-0.8.40-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:991e745aa51abfb8abfb0e1525b9309df03a2f67fdd8df96e18f91fe7fe06806", size = 4362227, upload-time = "2025-10-11T11:53:16.452Z" },
- { url = "https://files.pythonhosted.org/packages/c0/cb/6b4254f8a33e075118512e55acf3485c155ea52c6c35d69a985bdc59297c/hdbscan-0.8.40-cp312-cp312-win_amd64.whl", hash = "sha256:1b55a935ed7b329adac52072e1c4028979dfc54312ca08de2deece9c97d6ebb1", size = 726198, upload-time = "2024-11-18T16:18:09.99Z" },
-]
-
[[package]]
name = "hf-xet"
version = "1.5.1"
@@ -872,21 +873,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
-[[package]]
-name = "imagehash"
-version = "4.3.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
- { name = "pillow" },
- { name = "pywavelets" },
- { name = "scipy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/6c/f4/9821fe373a4788bca43f00491b008f930de0b12a60ff631852d1f984b966/ImageHash-4.3.1.tar.gz", hash = "sha256:7038d1b7f9e0585beb3dd8c0a956f02b95a346c0b5f24a9e8cc03ebadaf0aa70", size = 296989, upload-time = "2022-09-28T08:44:39.943Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2d/b4/19a746a986c6e38595fa5947c028b1b8e287773dcad766e648897ad2a4cf/ImageHash-4.3.1-py2.py3-none-any.whl", hash = "sha256:5ad9a5cde14fe255745a8245677293ac0d67f09c330986a351f34b614ba62fb5", size = 296543, upload-time = "2022-09-28T08:44:37.573Z" },
-]
-
[[package]]
name = "imageio"
version = "2.37.3"
@@ -1219,7 +1205,9 @@ name = "nvidia-cublas-cu12"
version = "12.8.4.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/29/99/db44d685f0e257ff0e213ade1964fc459b4a690a73293220e98feb3307cf/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b86f6dd8935884615a0683b663891d43781b819ac4f2ba2b0c9604676af346d0", size = 590537124, upload-time = "2025-03-07T01:43:53.556Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" },
+ { url = "https://files.pythonhosted.org/packages/70/61/7d7b3c70186fb651d0fbd35b01dbfc8e755f69fd58f817f3d0f642df20c3/nvidia_cublas_cu12-12.8.4.1-py3-none-win_amd64.whl", hash = "sha256:47e9b82132fa8d2b4944e708049229601448aaad7e6f296f630f2d1a32de35af", size = 567544208, upload-time = "2025-03-07T01:53:30.535Z" },
]
[[package]]
@@ -1227,7 +1215,9 @@ name = "nvidia-cuda-cupti-cu12"
version = "12.8.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/1f/b3bd73445e5cb342727fd24fe1f7b748f690b460acadc27ea22f904502c8/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4412396548808ddfed3f17a467b104ba7751e6b58678a4b840675c56d21cf7ed", size = 9533318, upload-time = "2025-03-07T01:40:10.421Z" },
{ url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" },
+ { url = "https://files.pythonhosted.org/packages/41/bc/83f5426095d93694ae39fe1311431b5d5a9bb82e48bf0dd8e19be2765942/nvidia_cuda_cupti_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:bb479dcdf7e6d4f8b0b01b115260399bf34154a1a2e9fe11c85c517d87efd98e", size = 7015759, upload-time = "2025-03-07T01:51:11.355Z" },
]
[[package]]
@@ -1236,6 +1226,8 @@ version = "12.8.93"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d1/e50d0acaab360482034b84b6e27ee83c6738f7d32182b987f9c7a4e32962/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc1fec1e1637854b4c0a65fb9a8346b51dd9ee69e61ebaccc82058441f15bce8", size = 43106076, upload-time = "2025-03-07T01:41:59.817Z" },
+ { url = "https://files.pythonhosted.org/packages/45/51/52a3d84baa2136cc8df15500ad731d74d3a1114d4c123e043cb608d4a32b/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-win_amd64.whl", hash = "sha256:7a4b6b2904850fe78e0bd179c4b655c404d4bb799ef03ddc60804247099ae909", size = 73586838, upload-time = "2025-03-07T01:52:13.483Z" },
]
[[package]]
@@ -1243,7 +1235,9 @@ name = "nvidia-cuda-runtime-cu12"
version = "12.8.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/75/f865a3b236e4647605ea34cc450900854ba123834a5f1598e160b9530c3a/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:52bf7bbee900262ffefe5e9d5a2a69a30d97e2bc5bb6cc866688caa976966e3d", size = 965265, upload-time = "2025-03-07T01:39:43.533Z" },
{ url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a5/a515b7600ad361ea14bfa13fb4d6687abf500adc270f19e89849c0590492/nvidia_cuda_runtime_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:c0c6027f01505bfed6c3b21ec546f69c687689aad5f1a377554bc6ca4aa993a8", size = 944318, upload-time = "2025-03-07T01:51:01.794Z" },
]
[[package]]
@@ -1251,10 +1245,12 @@ name = "nvidia-cudnn-cu12"
version = "9.10.2.21"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" },
+ { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'win32' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
]
wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/41/e79269ce215c857c935fd86bcfe91a451a584dfc27f1e068f568b9ad1ab7/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8", size = 705026878, upload-time = "2025-06-06T21:52:51.348Z" },
{ url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/90/0bd6e586701b3a890fd38aa71c387dab4883d619d6e5ad912ccbd05bfd67/nvidia_cudnn_cu12-9.10.2.21-py3-none-win_amd64.whl", hash = "sha256:c6288de7d63e6cf62988f0923f96dc339cea362decb1bf5b3141883392a7d65e", size = 692992268, upload-time = "2025-06-06T21:55:18.114Z" },
]
[[package]]
@@ -1262,10 +1258,12 @@ name = "nvidia-cufft-cu12"
version = "11.3.3.83"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" },
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'win32' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
]
wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/bc/7771846d3a0272026c416fbb7e5f4c1f146d6d80704534d0b187dd6f4800/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211, upload-time = "2025-03-07T01:44:56.873Z" },
{ url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ec/ce1629f1e478bb5ccd208986b5f9e0316a78538dd6ab1d0484f012f8e2a1/nvidia_cufft_cu12-11.3.3.83-py3-none-win_amd64.whl", hash = "sha256:7a64a98ef2a7c47f905aaf8931b69a3a43f27c55530c698bb2ed7c75c0b42cb7", size = 192216559, upload-time = "2025-03-07T01:53:57.106Z" },
]
[[package]]
@@ -1274,6 +1272,7 @@ version = "1.13.1.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/f5/5607710447a6fe9fd9b3283956fceeee8a06cda1d2f56ce31371f595db2a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:4beb6d4cce47c1a0f1013d72e02b0994730359e17801d395bdcbf20cfb3bb00a", size = 1120705, upload-time = "2025-03-07T01:45:41.434Z" },
]
[[package]]
@@ -1281,7 +1280,9 @@ name = "nvidia-curand-cu12"
version = "10.3.9.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/5e/92aa15eca622a388b80fbf8375d4760738df6285b1e92c43d37390a33a9a/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dfab99248034673b779bc6decafdc3404a8a6f502462201f2f31f11354204acd", size = 63625754, upload-time = "2025-03-07T01:46:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/75/70c05b2f3ed5be3bb30b7102b6eb78e100da4bbf6944fd6725c012831cab/nvidia_curand_cu12-10.3.9.90-py3-none-win_amd64.whl", hash = "sha256:f149a8ca457277da854f89cf282d6ef43176861926c7ac85b2a0fbd237c587ec", size = 62765309, upload-time = "2025-03-07T01:54:20.478Z" },
]
[[package]]
@@ -1289,12 +1290,14 @@ name = "nvidia-cusolver-cu12"
version = "11.7.3.90"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" },
- { name = "nvidia-cusparse-cu12", marker = "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" },
- { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" },
+ { name = "nvidia-cublas-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'win32' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cusparse-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'win32' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'win32' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
]
wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/32/f7cd6ce8a7690544d084ea21c26e910a97e077c9b7f07bf5de623ee19981/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841, upload-time = "2025-03-07T01:46:54.356Z" },
{ url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c0/76ca8551b8a84146ffa189fec81c26d04adba4bc0dbe09cd6e6fd9b7de04/nvidia_cusolver_cu12-11.7.3.90-py3-none-win_amd64.whl", hash = "sha256:4a550db115fcabc4d495eb7d39ac8b58d4ab5d8e63274d3754df1c0ad6a22d34", size = 256720438, upload-time = "2025-03-07T01:54:39.898Z" },
]
[[package]]
@@ -1302,10 +1305,12 @@ name = "nvidia-cusparse-cu12"
version = "12.5.8.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" },
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'win32' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
]
wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/f7/cd777c4109681367721b00a106f491e0d0d15cfa1fd59672ce580ce42a97/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129, upload-time = "2025-03-07T01:47:40.407Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
+ { url = "https://files.pythonhosted.org/packages/62/07/f3b2ad63f8e3d257a599f422ae34eb565e70c41031aecefa3d18b62cabd1/nvidia_cusparse_cu12-12.5.8.93-py3-none-win_amd64.whl", hash = "sha256:9a33604331cb2cac199f2e7f5104dfbb8a5a898c367a53dfda9ff2acb6b6b4dd", size = 284937404, upload-time = "2025-03-07T01:55:07.742Z" },
]
[[package]]
@@ -1313,7 +1318,9 @@ name = "nvidia-cusparselt-cu12"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/73/b9/598f6ff36faaece4b3c50d26f50e38661499ff34346f00e057760b35cc9d/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8878dce784d0fac90131b6817b607e803c36e629ba34dc5b433471382196b6a5", size = 283835557, upload-time = "2025-02-26T00:16:54.265Z" },
{ url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d8/a6b0d0d0c2435e9310f3e2bb0d9c9dd4c33daef86aa5f30b3681defd37ea/nvidia_cusparselt_cu12-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f67fbb5831940ec829c9117b7f33807db9f9678dc2a617fbe781cac17b4e1075", size = 271020911, upload-time = "2025-02-26T00:14:47.204Z" },
]
[[package]]
@@ -1321,6 +1328,7 @@ name = "nvidia-nccl-cu12"
version = "2.27.3"
source = { registry = "https://pypi.org/simple" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/7b/8354b784cf73b0ba51e566b4baba3ddd44fe8288a3d39ef1e06cd5417226/nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ddf1a245abc36c550870f26d537a9b6087fb2e2e3d6e0ef03374c6fd19d984f", size = 322397768, upload-time = "2025-06-03T21:57:30.234Z" },
{ url = "https://files.pythonhosted.org/packages/5c/5b/4e4fff7bad39adf89f735f2bc87248c81db71205b62bcc0d5ca5b606b3c3/nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039", size = 322364134, upload-time = "2025-06-03T21:58:04.013Z" },
]
@@ -1330,6 +1338,8 @@ version = "12.8.93"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a2/8cee5da30d13430e87bf99bb33455d2724d0a4a9cb5d7926d80ccb96d008/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:adccd7161ace7261e01bb91e44e88da350895c270d23f744f0820c818b7229e7", size = 38386204, upload-time = "2025-03-07T01:49:43.612Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/d7/34f02dad2e30c31b10a51f6b04e025e5dd60e5f936af9045a9b858a05383/nvidia_nvjitlink_cu12-12.8.93-py3-none-win_amd64.whl", hash = "sha256:bd93fbeeee850917903583587f4fc3a4eafa022e34572251368238ab5e6bd67f", size = 268553710, upload-time = "2025-03-07T01:56:24.13Z" },
]
[[package]]
@@ -1337,7 +1347,9 @@ name = "nvidia-nvtx-cu12"
version = "12.8.90"
source = { registry = "https://pypi.org/simple" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/c0/1b303feea90d296f6176f32a2a70b5ef230f9bdeb3a72bddb0dc922dc137/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7ad891da111ebafbf7e015d34879f7112832fc239ff0d7d776b6cb685274615", size = 91161, upload-time = "2025-03-07T01:42:23.922Z" },
{ url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/99/4c9c0c329bf9fc125008c3b54c7c94c0023518d06fc025ae36431375e1fe/nvidia_nvtx_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:619c8304aedc69f02ea82dd244541a83c3d9d40993381b3b590f1adaed3db41e", size = 56492, upload-time = "2025-03-07T01:52:24.69Z" },
]
[[package]]
@@ -1361,6 +1373,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/2a/8ce48d8ae26a8761ad4e5dc771961b155c5c3c7c8540ec7f2f2d71b69af0/onnx-1.22.0-cp312-abi3-win_arm64.whl", hash = "sha256:f3c120dcdb70ad738f3c061b32798f408ea299eb69f84dd69ab4a6bf3c2ec01f", size = 17207030, upload-time = "2026-06-15T12:49:48.635Z" },
]
+[[package]]
+name = "onnxruntime"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coloredlogs" },
+ { name = "flatbuffers" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "protobuf" },
+ { name = "sympy" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/20/f772eda98eec5c874c088e9cc040180ef31588407c51c8f9cfa64cb386fb/onnxruntime-1.17.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:90c0890e36f880281c6c698d9bc3de2afbeee2f76512725ec043665c25c67d21", size = 14765690, upload-time = "2024-01-31T18:34:20.972Z" },
+ { url = "https://files.pythonhosted.org/packages/55/1c/8b437ba686d6b62da914dc4e9ba51ce79a6b06c9a32c00195ac1a9e36592/onnxruntime-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7466724e809a40e986b1637cba156ad9fc0d1952468bc00f79ef340bc0199552", size = 5845437, upload-time = "2024-01-31T18:34:23.432Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/3c/e0f4636315e4be029c2d7ceb3a9232a441fb9afbcd1db01e222d06de1d34/onnxruntime-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d47bee7557a8b99c8681b6882657a515a4199778d6d5e24e924d2aafcef55b0a", size = 6787644, upload-time = "2024-01-31T18:34:26.029Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1b/f2c0a2f657931d88487487297a4db425342b6fd064e153c4e852623e4d55/onnxruntime-1.17.0-cp312-cp312-win32.whl", hash = "sha256:bb1bf1ee575c665b8bbc3813ab906e091a645a24ccc210be7932154b8260eca1", size = 4927080, upload-time = "2024-01-31T18:34:28.553Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/75/9d38bb1f2ffca4c353aaefdbfed2fa59fa7b2aaec255da6f03072759764c/onnxruntime-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac2f286da3494b29b4186ca193c7d4e6a2c1f770c4184c7192c5da142c3dec28", size = 5594153, upload-time = "2024-01-31T18:34:30.411Z" },
+]
+
[[package]]
name = "onnxruntime-gpu"
version = "1.17.0"
@@ -1388,8 +1420,12 @@ dependencies = [
{ name = "regex" },
{ name = "safetensors" },
{ name = "timm" },
- { name = "torch" },
- { name = "torchvision" },
+ { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (platform_machine != 'aarch64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torchvision", version = "0.23.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "extra == 'extra-12-find-backend-nvidia'" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/1f/2bc9795047fa2c1ad2567ef78ce6dfc9a7b763fa534acee09a94da2a5b8f/open_clip_torch-3.3.0.tar.gz", hash = "sha256:904b1a9f909df8281bb3de60ab95491cd2994a509177ea4f9d6292a84fe24d6d", size = 1503380, upload-time = "2026-02-27T00:32:46.74Z" }
@@ -1483,7 +1519,7 @@ name = "paddleocr"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "paddlex", extra = ["ocr-core"] },
+ { name = "paddlex", extra = ["ocr-core"], marker = "extra == 'extra-12-find-backend-cpu' or extra == 'extra-12-find-backend-nvidia'" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "typing-extensions" },
@@ -1981,7 +2017,7 @@ name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
@@ -2070,25 +2106,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
-[[package]]
-name = "pywavelets"
-version = "1.9.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/37/3fda13fb2518fdd306528382d6b18c116ceafefff0a7dccd28f1034f4dd2/pywavelets-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30baa0788317d3c938560c83fe4fc43817342d06e6c9662a440f73ba3fb25c9b", size = 4320835, upload-time = "2025-08-04T16:19:04.855Z" },
- { url = "https://files.pythonhosted.org/packages/36/65/a5549325daafc3eae4b52de076798839eaf529a07218f8fb18cccefe76a1/pywavelets-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:df7436a728339696a7aa955c020ae65c85b0d9d2b5ff5b4cf4551f5d4c50f2c7", size = 4290469, upload-time = "2025-08-04T16:19:06.178Z" },
- { url = "https://files.pythonhosted.org/packages/05/85/901bb756d37dfa56baa26ef4a3577aecfe9c55f50f51366fede322f8c91d/pywavelets-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07b26526db2476974581274c43a9c2447c917418c6bd03c8d305ad2a5cd9fac3", size = 4437717, upload-time = "2025-08-04T16:19:07.514Z" },
- { url = "https://files.pythonhosted.org/packages/0f/34/0f54dd9c288941294898877008bcb5c07012340cc9c5db9cff1bd185d449/pywavelets-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:573b650805d2f3c981a0e5ae95191c781a722022c37a0f6eba3fa7eae8e0ee17", size = 4483843, upload-time = "2025-08-04T16:19:08.857Z" },
- { url = "https://files.pythonhosted.org/packages/48/1f/cff6bb4ea64ff508d8cac3fe113c0aa95310a7446d9efa6829027cc2afdf/pywavelets-1.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3747ec804492436de6e99a7b6130480e53406d047e87dc7095ab40078a515a23", size = 4442236, upload-time = "2025-08-04T16:19:11.061Z" },
- { url = "https://files.pythonhosted.org/packages/ce/53/a3846eeefe0fb7ca63ae045f038457aa274989a15af793c1b824138caf98/pywavelets-1.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5163665686219c3f43fd5bbfef2391e87146813961dad0f86c62d4aed561f547", size = 4488077, upload-time = "2025-08-04T16:19:12.333Z" },
- { url = "https://files.pythonhosted.org/packages/f7/98/44852d2fe94455b72dece2db23562145179d63186a1c971125279a1c381f/pywavelets-1.9.0-cp312-cp312-win32.whl", hash = "sha256:80b8ab99f5326a3e724f71f23ba8b0a5b03e333fa79f66e965ea7bed21d42a2f", size = 4134094, upload-time = "2025-08-04T16:19:13.564Z" },
- { url = "https://files.pythonhosted.org/packages/2c/a7/0d9ee3fe454d606e0f5c8e3aebf99d2ecddbfb681826a29397729538c8f1/pywavelets-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:92bfb8a117b8c8d3b72f2757a85395346fcbf37f50598880879ae72bd8e1c4b9", size = 4213900, upload-time = "2025-08-04T16:19:14.939Z" },
-]
-
[[package]]
name = "pyyaml"
version = "6.0.2"
@@ -2370,7 +2387,7 @@ name = "sqlalchemy"
version = "2.0.49"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
+ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
@@ -2439,8 +2456,12 @@ dependencies = [
{ name = "huggingface-hub" },
{ name = "pyyaml" },
{ name = "safetensors" },
- { name = "torch" },
- { name = "torchvision" },
+ { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (platform_machine != 'aarch64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torchvision", version = "0.23.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "extra == 'extra-12-find-backend-nvidia'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/1e/e924b3b2326a856aaf68586f9c52a5fc81ef45715eca408393b68c597e0e/timm-1.0.26.tar.gz", hash = "sha256:f66f082f2f381cf68431c22714c8b70f723837fa2a185b155961eab90f2d5b10", size = 2419859, upload-time = "2026-03-23T18:12:10.272Z" }
wheels = [
@@ -2482,47 +2503,142 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
+[[package]]
+name = "torch"
+version = "2.8.0"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+resolution-markers = [
+ "sys_platform == 'darwin'",
+]
+dependencies = [
+ { name = "filelock", marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "fsspec", marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "jinja2", marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "networkx", marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "setuptools", marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "sympy", marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "typing-extensions", marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+]
+wheels = [
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:a47b7986bee3f61ad217d8a8ce24605809ab425baf349f97de758815edd2ef54", upload-time = "2025-10-01T23:35:50Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.8.0+cpu"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+resolution-markers = [
+ "platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "sys_platform == 'win32'",
+ "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "filelock", marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "fsspec", marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "jinja2", marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "networkx", marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "setuptools", marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "sympy", marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "typing-extensions", marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+]
+wheels = [
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:0e34e276722ab7dd0dffa9e12fe2135a9b34a0e300c456ed7ad6430229404eb5", upload-time = "2025-10-01T23:33:41Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:610f600c102386e581327d5efc18c0d6edecb9820b4140d26163354a99cd800d", upload-time = "2025-10-01T23:33:45Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cb9a8ba8137ab24e36bf1742cb79a1294bd374db570f09fc15a5e1318160db4e", upload-time = "2025-10-01T23:33:48Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:2be20b2c05a0cce10430cc25f32b689259640d273232b2de357c35729132256d", upload-time = "2025-10-01T23:33:52Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:99fc421a5d234580e45957a7b02effbf3e1c884a5dd077afc85352c77bf41434", upload-time = "2025-10-01T23:34:10Z" },
+]
+
[[package]]
name = "torch"
version = "2.8.0+cu128"
source = { registry = "https://download.pytorch.org/whl/cu128" }
-dependencies = [
- { name = "filelock" },
- { name = "fsspec" },
- { name = "jinja2" },
- { name = "networkx" },
- { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "setuptools" },
- { name = "sympy" },
- { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
- { name = "typing-extensions" },
+resolution-markers = [
+ "sys_platform == 'darwin'",
+ "platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "sys_platform == 'win32'",
+ "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "filelock", marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "fsspec", marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "jinja2", marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "networkx", marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "nvidia-cublas-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cuda-cupti-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cuda-nvrtc-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cuda-runtime-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cudnn-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cufft-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cufile-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-curand-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cusolver-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cusparse-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-cusparselt-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-nccl-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "nvidia-nvtx-cu12", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "setuptools", marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "sympy", marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "triton", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (platform_machine != 'x86_64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "typing-extensions", marker = "extra == 'extra-12-find-backend-nvidia'" },
]
wheels = [
{ url = "https://download-r2.pytorch.org/whl/cu128/torch-2.8.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4354fc05bb79b208d6995a04ca1ceef6a9547b1c4334435574353d381c55087c", upload-time = "2025-10-01T23:51:02Z" },
{ url = "https://download-r2.pytorch.org/whl/cu128/torch-2.8.0%2Bcu128-cp312-cp312-win_amd64.whl", hash = "sha256:0ad925202387f4e7314302a1b4f8860fa824357f9b1466d7992bf276370ebcff", upload-time = "2025-10-01T23:51:26Z" },
]
+[[package]]
+name = "torchvision"
+version = "0.23.0"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+resolution-markers = [
+ "sys_platform == 'darwin'",
+ "platform_machine == 'aarch64' and sys_platform == 'linux'",
+]
+dependencies = [
+ { name = "numpy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (platform_machine != 'aarch64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "pillow", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (platform_machine != 'aarch64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (platform_machine != 'aarch64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+]
+wheels = [
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e0e2c04a91403e8dd3af9756c6a024a1d9c0ed9c0d592a8314ded8f4fe30d440", upload-time = "2025-08-05T20:11:47Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6dd7c4d329a0e03157803031bc856220c6155ef08c26d4f5bbac938acecf0948", upload-time = "2025-08-05T20:11:47Z" },
+]
+
+[[package]]
+name = "torchvision"
+version = "0.23.0+cpu"
+source = { registry = "https://download.pytorch.org/whl/cpu" }
+resolution-markers = [
+ "sys_platform == 'win32'",
+ "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
+]
+dependencies = [
+ { name = "numpy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "pillow", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+]
+wheels = [
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.23.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ae459d4509d3b837b978dc6c66106601f916b6d2cda75c137e3f5f48324ce1da", upload-time = "2025-08-05T20:11:47Z" },
+ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.23.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:a651ccc540cf4c87eb988730c59c2220c52b57adc276f044e7efb9830fa65a1d", upload-time = "2025-08-05T20:11:47Z" },
+]
+
[[package]]
name = "torchvision"
version = "0.23.0+cu128"
source = { registry = "https://download.pytorch.org/whl/cu128" }
+resolution-markers = [
+ "sys_platform == 'darwin'",
+ "platform_machine == 'aarch64' and sys_platform == 'linux'",
+ "sys_platform == 'win32'",
+ "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
+]
dependencies = [
- { name = "numpy" },
- { name = "pillow" },
- { name = "torch" },
+ { name = "numpy", marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "pillow", marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "extra == 'extra-12-find-backend-nvidia'" },
]
wheels = [
{ url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.23.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9cb3c13997afcb44057ca10d943c6c4cba3068afde0f370965abce9c89fcffa9", upload-time = "2025-08-05T20:11:52Z" },
@@ -2566,7 +2682,7 @@ name = "triton"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "setuptools", marker = "(platform_machine != 'aarch64' and platform_machine != 's390x' and sys_platform == 'linux') or (platform_machine != 's390x' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" },
+ { name = "setuptools", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-nvidia') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'win32' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/66/b1eb52839f563623d185f0927eb3530ee4d5ffe9d377cdaf5346b306689e/triton-3.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c1d84a5c0ec2c0f8e8a072d7fd150cab84a9c239eaddc6706c081bfae4eb04", size = 155560068, upload-time = "2025-07-30T19:58:37.081Z" },
@@ -2658,8 +2774,12 @@ dependencies = [
{ name = "pyyaml" },
{ name = "requests" },
{ name = "scipy" },
- { name = "torch" },
- { name = "torchvision" },
+ { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "extra == 'extra-12-find-backend-nvidia'" },
+ { name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (platform_machine != 'aarch64' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform != 'darwin' and sys_platform != 'linux' and extra == 'extra-12-find-backend-cpu') or (sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'linux' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torchvision", version = "0.23.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "extra == 'extra-12-find-backend-nvidia'" },
{ name = "ultralytics-thop" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/67/90d6af67ddaebddb8c727b3f1333c4c6f0eb87343f29bb874769af7492c4/ultralytics-8.4.45.tar.gz", hash = "sha256:cbf7d308a4854a290ee383f86b1883e9aa24563b530c8e69a08eeaa06890cae0", size = 1038113, upload-time = "2026-04-29T15:17:41.513Z" }
@@ -2673,7 +2793,9 @@ version = "2.0.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
- { name = "torch" },
+ { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-12-find-backend-cpu') or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
+ { name = "torch", version = "2.8.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "extra == 'extra-12-find-backend-nvidia'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/ab/1cc7edfd5241ed9abb2e8ee0d5088d2800c989df9baa43e05706a87f765b/ultralytics_thop-2.0.19.tar.gz", hash = "sha256:df547d93ebdec4c9d2a9375f2e21d483547a462c08814e8810dc73498dd29ba9", size = 34665, upload-time = "2026-04-24T12:21:23.844Z" }
wheels = [
@@ -2704,11 +2826,11 @@ wheels = [
[package.optional-dependencies]
standard = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
- { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
+ { name = "uvloop", marker = "(platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32') or (platform_python_implementation == 'PyPy' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'cygwin' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia') or (sys_platform == 'win32' and extra == 'extra-12-find-backend-cpu' and extra == 'extra-12-find-backend-nvidia')" },
{ name = "watchfiles" },
{ name = "websockets" },
]
From 430035fa8d9a19aa8bd05ac8576aa600207edb58 Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Mon, 13 Jul 2026 02:10:01 +0530
Subject: [PATCH 02/21] feat: ship the timeline-first application experience
---
frontend/e2e/app-shell.spec.ts | 28 +-
frontend/public/maps/NATURAL_EARTH_NOTICE.md | 9 +
frontend/public/maps/ne_110m_land.geojson | 1 +
frontend/src/__tests__/account-auth.test.tsx | 170 ++++++
.../__tests__/album-detail-viewer.test.tsx | 21 +
frontend/src/__tests__/app-shell.test.tsx | 171 ++++++
frontend/src/__tests__/archive-trash.test.tsx | 26 +-
frontend/src/__tests__/asset-viewer.test.tsx | 33 ++
frontend/src/__tests__/map-page.test.tsx | 169 ++++++
frontend/src/__tests__/media-timeline.test.ts | 73 +++
.../src/__tests__/private-map-style.test.ts | 54 ++
.../__tests__/public-shared-timeline.test.tsx | 146 +++++
frontend/src/__tests__/root-page.test.tsx | 14 +
frontend/src/__tests__/settings-page.test.tsx | 102 +++-
.../__tests__/timeline-media-view.test.tsx | 115 ++++
frontend/src/__tests__/vault-gallery.test.tsx | 161 ++++++
frontend/src/__tests__/vault-store.test.ts | 32 ++
frontend/src/app/account/page.tsx | 296 ++++++++++
frontend/src/app/albums/[id]/page.tsx | 94 ++--
frontend/src/app/albums/page.tsx | 2 +-
frontend/src/app/archive/page.tsx | 58 +-
frontend/src/app/auth/login/page.tsx | 108 ++++
frontend/src/app/auth/setup/page.tsx | 149 +++++
frontend/src/app/clusters/page.tsx | 82 +--
frontend/src/app/gallery/page.tsx | 298 +++-------
frontend/src/app/globals.css | 92 ++-
frontend/src/app/layout.tsx | 36 +-
frontend/src/app/map/page.tsx | 241 ++++++++
frontend/src/app/page.tsx | 82 +--
frontend/src/app/people/page.tsx | 112 ++--
frontend/src/app/public/shared/[key]/page.tsx | 44 +-
frontend/src/app/search/page.tsx | 171 ++----
frontend/src/app/settings/page.tsx | 185 ++++++-
frontend/src/app/timeline/page.tsx | 149 ++++-
frontend/src/app/trash/page.tsx | 58 +-
frontend/src/components/CursorGlow.tsx | 54 --
frontend/src/components/NavBar.tsx | 378 ++++---------
.../src/components/ai-runtime-settings.tsx | 190 +++++++
frontend/src/components/app-shell.tsx | 302 ++++++++++
frontend/src/components/asset-viewer.tsx | 4 +-
.../components/hardware-accel-settings.tsx | 121 ++--
frontend/src/components/justified-grid.tsx | 6 +-
.../src/components/map-privacy-settings.tsx | 98 ++++
.../src/components/map-timeline-panel.tsx | 122 ++++
.../src/components/private-map.module.css | 104 ++++
frontend/src/components/private-map.tsx | 522 ++++++++++++++++++
.../src/components/timeline-media-view.tsx | 397 +++++++++++++
.../src/components/vault/VaultGallery.tsx | 457 ++++++++++-----
frontend/src/components/vault/VaultUnlock.tsx | 18 +-
frontend/src/components/vault/vault-client.ts | 78 +++
frontend/src/lib/api.ts | 167 +++++-
frontend/src/lib/media-timeline.ts | 234 ++++++++
frontend/src/lib/viewer-preload.ts | 2 +
53 files changed, 5486 insertions(+), 1350 deletions(-)
create mode 100644 frontend/public/maps/NATURAL_EARTH_NOTICE.md
create mode 100644 frontend/public/maps/ne_110m_land.geojson
create mode 100644 frontend/src/__tests__/account-auth.test.tsx
create mode 100644 frontend/src/__tests__/app-shell.test.tsx
create mode 100644 frontend/src/__tests__/map-page.test.tsx
create mode 100644 frontend/src/__tests__/media-timeline.test.ts
create mode 100644 frontend/src/__tests__/private-map-style.test.ts
create mode 100644 frontend/src/__tests__/public-shared-timeline.test.tsx
create mode 100644 frontend/src/__tests__/root-page.test.tsx
create mode 100644 frontend/src/__tests__/timeline-media-view.test.tsx
create mode 100644 frontend/src/__tests__/vault-gallery.test.tsx
create mode 100644 frontend/src/__tests__/vault-store.test.ts
create mode 100644 frontend/src/app/account/page.tsx
create mode 100644 frontend/src/app/auth/login/page.tsx
create mode 100644 frontend/src/app/auth/setup/page.tsx
create mode 100644 frontend/src/app/map/page.tsx
delete mode 100644 frontend/src/components/CursorGlow.tsx
create mode 100644 frontend/src/components/ai-runtime-settings.tsx
create mode 100644 frontend/src/components/app-shell.tsx
create mode 100644 frontend/src/components/map-privacy-settings.tsx
create mode 100644 frontend/src/components/map-timeline-panel.tsx
create mode 100644 frontend/src/components/private-map.module.css
create mode 100644 frontend/src/components/private-map.tsx
create mode 100644 frontend/src/components/timeline-media-view.tsx
create mode 100644 frontend/src/components/vault/vault-client.ts
create mode 100644 frontend/src/lib/media-timeline.ts
diff --git a/frontend/e2e/app-shell.spec.ts b/frontend/e2e/app-shell.spec.ts
index 6b0088e9..3d4193bc 100644
--- a/frontend/e2e/app-shell.spec.ts
+++ b/frontend/e2e/app-shell.spec.ts
@@ -20,17 +20,26 @@ test.describe("app shell", () => {
// Brand mark in the sticky nav.
await expect(page.getByRole("link", { name: /FIND\./ })).toBeVisible();
// Footer carries the AGPL license line (relicensed per §1).
- await expect(page.getByText(/AGPL-3\.0 License/)).toBeVisible();
+ await expect(page.getByText(/AGPL-3\.0 License/).first()).toBeVisible();
});
- test("exposes the new feature routes in the nav", async ({ page }) => {
+ test("redirects home to Photos and exposes the feature routes", async ({
+ page,
+ }) => {
await page.goto("/");
- // The desktop nav lists the overhaul features.
+ await expect(page).toHaveURL(/\/timeline\/?$/);
+
+ // The desktop sidebar lists the core browsing and management surfaces.
for (const label of [
- "Timeline",
+ "Photos",
+ "Search",
+ "Map",
+ "People",
"Albums",
+ "Favorites",
"Archive",
+ "Vault",
"Trash",
"Settings",
]) {
@@ -40,20 +49,17 @@ test.describe("app shell", () => {
}
});
- test("navigates to the settings route and shows the accel toggle", async ({
+ test("navigates to the settings route and shows its static shell", async ({
page,
}) => {
await page.goto("/settings");
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
- // The hardware-acceleration section heading renders regardless of backend.
- await expect(
- page.getByRole("heading", { name: "Hardware acceleration" }),
- ).toBeVisible();
+ await expect(page.getByText("Local-first controls")).toBeVisible();
});
- test("renders the timeline heading", async ({ page }) => {
+ test("renders the Photos timeline heading", async ({ page }) => {
await page.goto("/timeline");
- await expect(page.getByRole("heading", { name: "Timeline" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Photos" })).toBeVisible();
});
});
diff --git a/frontend/public/maps/NATURAL_EARTH_NOTICE.md b/frontend/public/maps/NATURAL_EARTH_NOTICE.md
new file mode 100644
index 00000000..c6ca25c9
--- /dev/null
+++ b/frontend/public/maps/NATURAL_EARTH_NOTICE.md
@@ -0,0 +1,9 @@
+# Natural Earth map data
+
+`ne_110m_land.geojson` is the Natural Earth 1:110m land dataset, obtained
+from the Natural Earth vector repository. Natural Earth raster and vector map
+data is public domain and may be used and modified without permission.
+
+Source: https://github.com/nvkelso/natural-earth-vector
+
+Terms: https://www.naturalearthdata.com/about/terms-of-use/
diff --git a/frontend/public/maps/ne_110m_land.geojson b/frontend/public/maps/ne_110m_land.geojson
new file mode 100644
index 00000000..04811d72
--- /dev/null
+++ b/frontend/public/maps/ne_110m_land.geojson
@@ -0,0 +1 @@
+{"type":"FeatureCollection","name":"ne_110m_land","crs":{"type":"name","properties":{"name":"urn:ogc:def:crs:OGC:1.3:CRS84"}},"features":[{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-66.290031,-81.000327,-59.572095,-79.628679],"geometry":{"type":"Polygon","coordinates":[[[-59.572095,-80.040179],[-59.865849,-80.549657],[-60.159656,-81.000327],[-62.255393,-80.863178],[-64.488125,-80.921934],[-65.741666,-80.588827],[-65.741666,-80.549657],[-66.290031,-80.255773],[-64.037688,-80.294944],[-61.883246,-80.39287],[-61.138976,-79.981371],[-60.610119,-79.628679],[-59.572095,-80.040179]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-163.712896,-79.634209,-159.208184,-78.223338],"geometry":{"type":"Polygon","coordinates":[[[-159.208184,-79.497059],[-161.127601,-79.634209],[-162.439847,-79.281465],[-163.027408,-78.928774],[-163.066604,-78.869966],[-163.712896,-78.595667],[-163.712896,-78.595667],[-163.105801,-78.223338],[-161.245113,-78.380176],[-160.246208,-78.693645],[-159.482405,-79.046338],[-159.208184,-79.497059]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":0},"bbox":[-54.164259,-81.025442,-43.333267,-77.831476],"geometry":{"type":"Polygon","coordinates":[[[-45.154758,-78.04707],[-43.920828,-78.478103],[-43.48995,-79.08556],[-43.372438,-79.516645],[-43.333267,-80.026123],[-44.880537,-80.339644],[-46.506174,-80.594357],[-48.386421,-80.829485],[-50.482107,-81.025442],[-52.851988,-80.966685],[-54.164259,-80.633528],[-53.987991,-80.222028],[-51.853134,-79.94773],[-50.991326,-79.614623],[-50.364595,-79.183487],[-49.914131,-78.811209],[-49.306959,-78.458569],[-48.660616,-78.047018],[-48.660616,-78.047019],[-48.151396,-78.04707],[-46.662857,-77.831476],[-45.154758,-78.04707]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-122.621735,-74.08881,-118.724143,-73.324619],"geometry":{"type":"Polygon","coordinates":[[[-121.211511,-73.50099],[-119.918851,-73.657725],[-118.724143,-73.481353],[-119.292119,-73.834097],[-120.232217,-74.08881],[-121.62283,-74.010468],[-122.621735,-73.657778],[-122.621735,-73.657777],[-122.406245,-73.324619],[-121.211511,-73.50099]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-127.28313,-73.873268,-124.031882,-73.246226],"geometry":{"type":"Polygon","coordinates":[[[-125.559566,-73.481353],[-124.031882,-73.873268],[-124.619469,-73.834097],[-125.912181,-73.736118],[-127.28313,-73.461769],[-127.28313,-73.461768],[-126.558472,-73.246226],[-125.559566,-73.481353]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-102.330725,-72.521205,-96.20035,-71.717792],"geometry":{"type":"Polygon","coordinates":[[[-98.98155,-71.933334],[-97.884743,-72.070535],[-96.787937,-71.952971],[-96.20035,-72.521205],[-96.983765,-72.442864],[-98.198083,-72.482035],[-99.432013,-72.442864],[-100.783455,-72.50162],[-101.801868,-72.305663],[-102.330725,-71.894164],[-102.330725,-71.894164],[-101.703967,-71.717792],[-100.430919,-71.854993],[-98.98155,-71.933334]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-75.012625,-72.503842,-68.333834,-68.87874],"geometry":{"type":"Polygon","coordinates":[[[-68.451346,-70.955823],[-68.333834,-71.406493],[-68.510128,-71.798407],[-68.784297,-72.170736],[-69.959471,-72.307885],[-71.075889,-72.503842],[-72.388134,-72.484257],[-71.8985,-72.092343],[-73.073622,-72.229492],[-74.19004,-72.366693],[-74.953895,-72.072757],[-75.012625,-71.661258],[-73.915819,-71.269345],[-73.915819,-71.269344],[-73.230331,-71.15178],[-72.074717,-71.190951],[-71.780962,-70.681473],[-71.72218,-70.309196],[-71.741791,-69.505782],[-71.173815,-69.035475],[-70.253252,-68.87874],[-69.724447,-69.251017],[-69.489422,-69.623346],[-69.058518,-70.074016],[-68.725541,-70.505153],[-68.451346,-70.955823]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-180,-90,180,-63.27066],"geometry":{"type":"Polygon","coordinates":[[[-58.614143,-64.152467],[-59.045073,-64.36801],[-59.789342,-64.211223],[-60.611928,-64.309202],[-61.297416,-64.54433],[-62.0221,-64.799094],[-62.51176,-65.09303],[-62.648858,-65.484942],[-62.590128,-65.857219],[-62.120079,-66.190326],[-62.805567,-66.425505],[-63.74569,-66.503847],[-64.294106,-66.837004],[-64.881693,-67.150474],[-65.508425,-67.58161],[-65.665082,-67.953887],[-65.312545,-68.365335],[-64.783715,-68.678908],[-63.961103,-68.913984],[-63.1973,-69.227556],[-62.785955,-69.619419],[-62.570516,-69.991747],[-62.276736,-70.383661],[-61.806661,-70.716768],[-61.512906,-71.089045],[-61.375809,-72.010074],[-61.081977,-72.382351],[-61.003661,-72.774265],[-60.690269,-73.166179],[-60.827367,-73.695242],[-61.375809,-74.106742],[-61.96337,-74.439848],[-63.295201,-74.576997],[-63.74569,-74.92974],[-64.352836,-75.262847],[-65.860987,-75.635124],[-67.192818,-75.79191],[-68.446282,-76.007452],[-69.797724,-76.222995],[-70.600724,-76.634494],[-72.206776,-76.673665],[-73.969536,-76.634494],[-75.555977,-76.712887],[-77.24037,-76.712887],[-76.926979,-77.104802],[-75.399294,-77.28107],[-74.282876,-77.55542],[-73.656119,-77.908112],[-74.772536,-78.221633],[-76.4961,-78.123654],[-77.925858,-78.378419],[-77.984666,-78.789918],[-78.023785,-79.181833],[-76.848637,-79.514939],[-76.633224,-79.887216],[-75.360097,-80.259545],[-73.244852,-80.416331],[-71.442946,-80.69063],[-70.013163,-81.004151],[-68.191646,-81.317672],[-65.704279,-81.474458],[-63.25603,-81.748757],[-61.552026,-82.042692],[-59.691416,-82.37585],[-58.712121,-82.846106],[-58.222487,-83.218434],[-57.008117,-82.865691],[-55.362894,-82.571755],[-53.619771,-82.258235],[-51.543644,-82.003521],[-49.76135,-81.729171],[-47.273931,-81.709586],[-44.825708,-81.846735],[-42.808363,-82.081915],[-42.16202,-81.65083],[-40.771433,-81.356894],[-38.244818,-81.337309],[-36.26667,-81.121715],[-34.386397,-80.906172],[-32.310296,-80.769023],[-30.097098,-80.592651],[-28.549802,-80.337938],[-29.254901,-79.985195],[-29.685805,-79.632503],[-29.685805,-79.260226],[-31.624808,-79.299397],[-33.681324,-79.456132],[-35.639912,-79.456132],[-35.914107,-79.083855],[-35.77701,-78.339248],[-35.326546,-78.123654],[-33.896763,-77.888526],[-32.212369,-77.65345],[-30.998051,-77.359515],[-29.783732,-77.065579],[-28.882779,-76.673665],[-27.511752,-76.497345],[-26.160336,-76.360144],[-25.474822,-76.281803],[-23.927552,-76.24258],[-22.458598,-76.105431],[-21.224694,-75.909474],[-20.010375,-75.674346],[-18.913543,-75.439218],[-17.522982,-75.125698],[-16.641589,-74.79254],[-15.701491,-74.498604],[-15.40771,-74.106742],[-16.46532,-73.871614],[-16.112784,-73.460114],[-15.446855,-73.146542],[-14.408805,-72.950585],[-13.311973,-72.715457],[-12.293508,-72.401936],[-11.510067,-72.010074],[-11.020433,-71.539767],[-10.295774,-71.265416],[-9.101015,-71.324224],[-8.611381,-71.65733],[-7.416622,-71.696501],[-7.377451,-71.324224],[-6.868232,-70.93231],[-5.790985,-71.030289],[-5.536375,-71.402617],[-4.341667,-71.461373],[-3.048981,-71.285053],[-1.795492,-71.167438],[-0.659489,-71.226246],[-0.228637,-71.637745],[0.868195,-71.304639],[1.886686,-71.128267],[3.022638,-70.991118],[4.139055,-70.853917],[5.157546,-70.618789],[6.273912,-70.462055],[7.13572,-70.246512],[7.742866,-69.893769],[8.48711,-70.148534],[9.525135,-70.011333],[10.249845,-70.48164],[10.817821,-70.834332],[11.953824,-70.638375],[12.404287,-70.246512],[13.422778,-69.972162],[14.734998,-70.030918],[15.126757,-70.403247],[15.949342,-70.030918],[17.026589,-69.913354],[18.201711,-69.874183],[19.259373,-69.893769],[20.375739,-70.011333],[21.452985,-70.07014],[21.923034,-70.403247],[22.569403,-70.697182],[23.666184,-70.520811],[24.841357,-70.48164],[25.977309,-70.48164],[27.093726,-70.462055],[28.09258,-70.324854],[29.150242,-70.20729],[30.031583,-69.93294],[30.971733,-69.75662],[31.990172,-69.658641],[32.754053,-69.384291],[33.302443,-68.835642],[33.870419,-68.502588],[34.908495,-68.659271],[35.300202,-69.012014],[36.16201,-69.247142],[37.200035,-69.168748],[37.905108,-69.52144],[38.649404,-69.776205],[39.667894,-69.541077],[40.020431,-69.109941],[40.921358,-68.933621],[41.959434,-68.600514],[42.938702,-68.463313],[44.113876,-68.267408],[44.897291,-68.051866],[45.719928,-67.816738],[46.503343,-67.601196],[47.44344,-67.718759],[48.344419,-67.366068],[48.990736,-67.091718],[49.930885,-67.111303],[50.753471,-66.876175],[50.949325,-66.523484],[51.791547,-66.249133],[52.614133,-66.053176],[53.613038,-65.89639],[54.53355,-65.818049],[55.414943,-65.876805],[56.355041,-65.974783],[57.158093,-66.249133],[57.255968,-66.680218],[58.137361,-67.013324],[58.744508,-67.287675],[59.939318,-67.405239],[60.605221,-67.679589],[61.427806,-67.953887],[62.387489,-68.012695],[63.19049,-67.816738],[64.052349,-67.405239],[64.992447,-67.620729],[65.971715,-67.738345],[66.911864,-67.855909],[67.891133,-67.934302],[68.890038,-67.934302],[69.712624,-68.972791],[69.673453,-69.227556],[69.555941,-69.678226],[68.596258,-69.93294],[67.81274,-70.305268],[67.949889,-70.697182],[69.066307,-70.677545],[68.929157,-71.069459],[68.419989,-71.441788],[67.949889,-71.853287],[68.71377,-72.166808],[69.869307,-72.264787],[71.024895,-72.088415],[71.573285,-71.696501],[71.906288,-71.324224],[72.454627,-71.010703],[73.08141,-70.716768],[73.33602,-70.364024],[73.864877,-69.874183],[74.491557,-69.776205],[75.62756,-69.737034],[76.626465,-69.619419],[77.644904,-69.462684],[78.134539,-69.07077],[78.428371,-68.698441],[79.113859,-68.326216],[80.093127,-68.071503],[80.93535,-67.875546],[81.483792,-67.542388],[82.051767,-67.366068],[82.776426,-67.209282],[83.775331,-67.30726],[84.676206,-67.209282],[85.655527,-67.091718],[86.752359,-67.150474],[87.477017,-66.876175],[87.986289,-66.209911],[88.358411,-66.484261],[88.828408,-66.954568],[89.67063,-67.150474],[90.630365,-67.228867],[91.5901,-67.111303],[92.608539,-67.189696],[93.548637,-67.209282],[94.17542,-67.111303],[95.017591,-67.170111],[95.781472,-67.385653],[96.682399,-67.248504],[97.759646,-67.248504],[98.68021,-67.111303],[99.718182,-67.248504],[100.384188,-66.915346],[100.893356,-66.58224],[101.578896,-66.30789],[102.832411,-65.563284],[103.478676,-65.700485],[104.242557,-65.974783],[104.90846,-66.327527],[106.181561,-66.934931],[107.160881,-66.954568],[108.081393,-66.954568],[109.15864,-66.837004],[110.235835,-66.699804],[111.058472,-66.425505],[111.74396,-66.13157],[112.860378,-66.092347],[113.604673,-65.876805],[114.388088,-66.072762],[114.897308,-66.386283],[115.602381,-66.699804],[116.699161,-66.660633],[117.384701,-66.915346],[118.57946,-67.170111],[119.832924,-67.268089],[120.871,-67.189696],[121.654415,-66.876175],[122.320369,-66.562654],[123.221296,-66.484261],[124.122274,-66.621462],[125.160247,-66.719389],[126.100396,-66.562654],[127.001427,-66.562654],[127.882768,-66.660633],[128.80328,-66.758611],[129.704259,-66.58224],[130.781454,-66.425505],[131.799945,-66.386283],[132.935896,-66.386283],[133.85646,-66.288304],[134.757387,-66.209963],[135.031582,-65.72007],[135.070753,-65.308571],[135.697485,-65.582869],[135.873805,-66.033591],[136.206705,-66.44509],[136.618049,-66.778197],[137.460271,-66.954568],[138.596223,-66.895761],[139.908442,-66.876175],[140.809421,-66.817367],[142.121692,-66.817367],[143.061842,-66.797782],[144.374061,-66.837004],[145.490427,-66.915346],[146.195552,-67.228867],[145.999699,-67.601196],[146.646067,-67.895131],[147.723263,-68.130259],[148.839629,-68.385024],[150.132314,-68.561292],[151.483705,-68.71813],[152.502247,-68.874813],[153.638199,-68.894502],[154.284567,-68.561292],[155.165857,-68.835642],[155.92979,-69.149215],[156.811132,-69.384291],[158.025528,-69.482269],[159.181013,-69.599833],[159.670699,-69.991747],[160.80665,-70.226875],[161.570479,-70.579618],[162.686897,-70.736353],[163.842434,-70.716768],[164.919681,-70.775524],[166.11444,-70.755938],[167.309095,-70.834332],[168.425616,-70.971481],[169.463589,-71.20666],[170.501665,-71.402617],[171.20679,-71.696501],[171.089227,-72.088415],[170.560422,-72.441159],[170.109958,-72.891829],[169.75737,-73.24452],[169.287321,-73.65602],[167.975101,-73.812806],[167.387489,-74.165498],[166.094803,-74.38104],[165.644391,-74.772954],[164.958851,-75.145283],[164.234193,-75.458804],[163.822797,-75.870303],[163.568239,-76.24258],[163.47026,-76.693302],[163.489897,-77.065579],[164.057873,-77.457442],[164.273363,-77.82977],[164.743464,-78.182514],[166.604126,-78.319611],[166.995781,-78.750748],[165.193876,-78.907483],[163.666217,-79.123025],[161.766385,-79.162248],[160.924162,-79.730482],[160.747894,-80.200737],[160.316964,-80.573066],[159.788211,-80.945395],[161.120016,-81.278501],[161.629287,-81.690001],[162.490992,-82.062278],[163.705336,-82.395435],[165.095949,-82.708956],[166.604126,-83.022477],[168.895665,-83.335998],[169.404782,-83.825891],[172.283934,-84.041433],[172.477049,-84.117914],[173.224083,-84.41371],[175.985672,-84.158997],[178.277212,-84.472518],[180,-84.71338],[180,-90],[-180,-90],[-180,-84.71338],[-179.942499,-84.721443],[-179.058677,-84.139412],[-177.256772,-84.452933],[-177.140807,-84.417941],[-176.861993,-84.333812],[-176.523952,-84.231811],[-176.230303,-84.143203],[-176.084673,-84.099259],[-175.934101,-84.101591],[-175.829882,-84.117914],[-174.382503,-84.534323],[-173.116559,-84.117914],[-172.889106,-84.061019],[-169.951223,-83.884647],[-168.999989,-84.117914],[-168.530199,-84.23739],[-167.022099,-84.570497],[-164.182144,-84.82521],[-161.929775,-85.138731],[-158.07138,-85.37391],[-155.192253,-85.09956],[-150.942099,-85.295517],[-148.533073,-85.609038],[-145.888918,-85.315102],[-143.107718,-85.040752],[-142.892279,-84.570497],[-146.829068,-84.531274],[-150.060732,-84.296146],[-150.902928,-83.904232],[-153.586201,-83.68869],[-153.409907,-83.23802],[-153.037759,-82.82652],[-152.665637,-82.454192],[-152.861517,-82.042692],[-154.526299,-81.768394],[-155.29018,-81.41565],[-156.83745,-81.102129],[-154.408787,-81.160937],[-152.097662,-81.004151],[-150.648293,-81.337309],[-148.865998,-81.043373],[-147.22075,-80.671045],[-146.417749,-80.337938],[-146.770286,-79.926439],[-148.062947,-79.652089],[-149.531901,-79.358205],[-151.588416,-79.299397],[-153.390322,-79.162248],[-155.329376,-79.064269],[-155.975668,-78.69194],[-157.268302,-78.378419],[-158.051768,-78.025676],[-158.365134,-76.889207],[-157.875474,-76.987238],[-156.974573,-77.300759],[-155.329376,-77.202728],[-153.742832,-77.065579],[-152.920247,-77.496664],[-151.33378,-77.398737],[-150.00195,-77.183143],[-148.748486,-76.908845],[-147.612483,-76.575738],[-146.104409,-76.47776],[-146.143528,-76.105431],[-146.496091,-75.733154],[-146.20231,-75.380411],[-144.909624,-75.204039],[-144.322037,-75.537197],[-142.794353,-75.34124],[-141.638764,-75.086475],[-140.209007,-75.06689],[-138.85759,-74.968911],[-137.5062,-74.733783],[-136.428901,-74.518241],[-135.214583,-74.302699],[-134.431194,-74.361455],[-133.745654,-74.439848],[-132.257168,-74.302699],[-130.925311,-74.479019],[-129.554284,-74.459433],[-128.242038,-74.322284],[-126.890622,-74.420263],[-125.402082,-74.518241],[-124.011496,-74.479019],[-122.562152,-74.498604],[-121.073613,-74.518241],[-119.70256,-74.479019],[-118.684145,-74.185083],[-117.469801,-74.028348],[-116.216312,-74.243891],[-115.021552,-74.067519],[-113.944331,-73.714828],[-113.297988,-74.028348],[-112.945452,-74.38104],[-112.299083,-74.714198],[-111.261059,-74.420263],[-110.066325,-74.79254],[-108.714909,-74.910103],[-107.559346,-75.184454],[-106.149148,-75.125698],[-104.876074,-74.949326],[-103.367949,-74.988497],[-102.016507,-75.125698],[-100.645531,-75.302018],[-100.1167,-74.870933],[-100.763043,-74.537826],[-101.252703,-74.185083],[-102.545337,-74.106742],[-103.113313,-73.734413],[-103.328752,-73.362084],[-103.681289,-72.61753],[-102.917485,-72.754679],[-101.60524,-72.813436],[-100.312528,-72.754679],[-99.13738,-72.911414],[-98.118889,-73.20535],[-97.688037,-73.558041],[-96.336595,-73.616849],[-95.043961,-73.4797],[-93.672907,-73.283743],[-92.439003,-73.166179],[-91.420564,-73.401307],[-90.088733,-73.322914],[-89.226951,-72.558722],[-88.423951,-73.009393],[-87.268337,-73.185764],[-86.014822,-73.087786],[-85.192236,-73.4797],[-83.879991,-73.518871],[-82.665646,-73.636434],[-81.470913,-73.851977],[-80.687447,-73.4797],[-80.295791,-73.126956],[-79.296886,-73.518871],[-77.925858,-73.420892],[-76.907367,-73.636434],[-76.221879,-73.969541],[-74.890049,-73.871614],[-73.852024,-73.65602],[-72.833533,-73.401307],[-71.619215,-73.264157],[-70.209042,-73.146542],[-68.935916,-73.009393],[-67.956622,-72.79385],[-67.369061,-72.480329],[-67.134036,-72.049244],[-67.251548,-71.637745],[-67.56494,-71.245831],[-67.917477,-70.853917],[-68.230843,-70.462055],[-68.485452,-70.109311],[-68.544209,-69.717397],[-68.446282,-69.325535],[-67.976233,-68.953206],[-67.5845,-68.541707],[-67.427843,-68.149844],[-67.62367,-67.718759],[-67.741183,-67.326845],[-67.251548,-66.876175],[-66.703184,-66.58224],[-66.056815,-66.209963],[-65.371327,-65.89639],[-64.568276,-65.602506],[-64.176542,-65.171423],[-63.628152,-64.897073],[-63.001394,-64.642308],[-62.041686,-64.583552],[-61.414928,-64.270031],[-60.709855,-64.074074],[-59.887269,-63.95651],[-59.162585,-63.701745],[-58.594557,-63.388224],[-57.811143,-63.27066],[-57.223582,-63.525425],[-57.59573,-63.858532],[-58.614143,-64.152467]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-74.66253,-55.61183,-65.05,-52.5183],"geometry":{"type":"Polygon","coordinates":[[[-67.75,-53.85],[-66.45,-54.45],[-65.05,-54.7],[-65.5,-55.2],[-66.45,-55.25],[-66.95992,-54.89681],[-67.29103,-55.30124],[-68.14863,-55.61183],[-69.2321,-55.49906],[-69.95809,-55.19843],[-71.00568,-55.05383],[-72.2639,-54.49514],[-73.2852,-53.95752],[-74.66253,-52.83749],[-73.8381,-53.04743],[-72.43418,-53.7154],[-71.10773,-54.07433],[-70.59178,-53.61583],[-70.26748,-52.93123],[-69.34565,-52.5183],[-68.63411,-52.63625],[-68.63401,-52.63637],[-68.25,-53.1],[-67.75,-53.85]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0.5},"bbox":[-61.2,-52.3,-57.75,-51.1],"geometry":{"type":"Polygon","coordinates":[[[-58.55,-51.1],[-57.75,-51.55],[-58.05,-51.9],[-59.4,-52.2],[-59.85,-51.85],[-60.7,-52.3],[-61.2,-51.85],[-60,-51.25],[-59.15,-51.5],[-58.55,-51.1]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0.5},"bbox":[68.72,-49.775,70.56,-48.625],"geometry":{"type":"Polygon","coordinates":[[[70.28,-49.71],[68.745,-49.775],[68.72,-49.2425],[68.8675,-48.83],[68.935,-48.625],[69.58,-48.94],[70.525,-49.065],[70.56,-49.255],[70.28,-49.71]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[144.718071,-43.634597,148.359865,-40.703975],"geometry":{"type":"Polygon","coordinates":[[[145.397978,-40.792549],[146.364121,-41.137695],[146.908584,-41.000546],[147.689259,-40.808258],[148.289068,-40.875438],[148.359865,-42.062445],[148.017301,-42.407024],[147.914052,-43.211522],[147.564564,-42.937689],[146.870343,-43.634597],[146.663327,-43.580854],[146.048378,-43.549745],[145.43193,-42.693776],[145.29509,-42.03361],[144.718071,-41.162552],[144.743755,-40.703975],[145.397978,-40.792549]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[166.509144,-46.641235,174.248517,-40.493962],"geometry":{"type":"Polygon","coordinates":[[[173.020375,-40.919052],[173.247234,-41.331999],[173.958405,-40.926701],[174.247587,-41.349155],[174.248517,-41.770008],[173.876447,-42.233184],[173.22274,-42.970038],[172.711246,-43.372288],[173.080113,-43.853344],[172.308584,-43.865694],[171.452925,-44.242519],[171.185138,-44.897104],[170.616697,-45.908929],[169.831422,-46.355775],[169.332331,-46.641235],[168.411354,-46.619945],[167.763745,-46.290197],[166.676886,-46.219917],[166.509144,-45.852705],[167.046424,-45.110941],[168.303763,-44.123973],[168.949409,-43.935819],[169.667815,-43.555326],[170.52492,-43.031688],[171.12509,-42.512754],[171.569714,-41.767424],[171.948709,-41.514417],[172.097227,-40.956104],[172.79858,-40.493962],[173.020375,-40.919052]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[172.636005,-41.688308,178.517094,-34.450662],"geometry":{"type":"Polygon","coordinates":[[[174.612009,-36.156397],[175.336616,-37.209098],[175.357596,-36.526194],[175.808887,-36.798942],[175.95849,-37.555382],[176.763195,-37.881253],[177.438813,-37.961248],[178.010354,-37.579825],[178.517094,-37.695373],[178.274731,-38.582813],[177.97046,-39.166343],[177.206993,-39.145776],[176.939981,-39.449736],[177.032946,-39.879943],[176.885824,-40.065978],[176.508017,-40.604808],[176.01244,-41.289624],[175.239567,-41.688308],[175.067898,-41.425895],[174.650973,-41.281821],[175.22763,-40.459236],[174.900157,-39.908933],[173.824047,-39.508854],[173.852262,-39.146602],[174.574802,-38.797683],[174.743474,-38.027808],[174.697017,-37.381129],[174.292028,-36.711092],[174.319004,-36.534824],[173.840997,-36.121981],[173.054171,-35.237125],[172.636005,-34.529107],[173.007042,-34.450662],[173.551298,-35.006183],[174.32939,-35.265496],[174.612009,-36.156397]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[164.029606,-22.399976,167.120011,-20.105646],"geometry":{"type":"Polygon","coordinates":[[[167.120011,-22.159991],[166.740035,-22.399976],[166.189732,-22.129708],[165.474375,-21.679607],[164.829815,-21.14982],[164.167995,-20.444747],[164.029606,-20.105646],[164.459967,-20.120012],[165.020036,-20.459991],[165.460009,-20.800022],[165.77999,-21.080005],[166.599991,-21.700019],[167.120011,-22.159991]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0.5},"bbox":[177.28504,-18.28799,178.71806,-17.33992],"geometry":{"type":"Polygon","coordinates":[[[178.3736,-17.33992],[178.71806,-17.62846],[178.55271,-18.15059],[177.93266,-18.28799],[177.38146,-18.16432],[177.28504,-17.72465],[177.67087,-17.38114],[178.12557,-17.50481],[178.3736,-17.33992]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[178.596839,-17.012042,180,-16.067133],"geometry":{"type":"Polygon","coordinates":[[[179.364143,-16.801354],[178.725059,-17.012042],[178.596839,-16.63915],[179.096609,-16.433984],[179.413509,-16.379054],[180,-16.067133],[180,-16.555217],[179.364143,-16.801354]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[-180,-16.555217,-179.79332,-16.020882],"geometry":{"type":"Polygon","coordinates":[[[-179.917369,-16.501783],[-180,-16.555217],[-180,-16.067133],[-179.79332,-16.020882],[-179.917369,-16.501783]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[167.180008,-16.59785,167.844877,-15.891846],"geometry":{"type":"Polygon","coordinates":[[[167.844877,-16.466333],[167.515181,-16.59785],[167.180008,-16.159995],[167.216801,-15.891846],[167.844877,-16.466333]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[166.629137,-15.740021,167.270028,-14.626497],"geometry":{"type":"Polygon","coordinates":[[[167.107712,-14.93392],[167.270028,-15.740021],[167.001207,-15.614602],[166.793158,-15.668811],[166.649859,-15.392704],[166.629137,-14.626497],[167.107712,-14.93392]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[43.254187,-25.601434,50.476537,-12.040557],"geometry":{"type":"Polygon","coordinates":[[[50.056511,-13.555761],[50.217431,-14.758789],[50.476537,-15.226512],[50.377111,-15.706069],[50.200275,-16.000263],[49.860606,-15.414253],[49.672607,-15.710204],[49.863344,-16.451037],[49.774564,-16.875042],[49.498612,-17.106036],[49.435619,-17.953064],[49.041792,-19.118781],[48.548541,-20.496888],[47.930749,-22.391501],[47.547723,-23.781959],[47.095761,-24.94163],[46.282478,-25.178463],[45.409508,-25.601434],[44.833574,-25.346101],[44.03972,-24.988345],[43.763768,-24.460677],[43.697778,-23.574116],[43.345654,-22.776904],[43.254187,-22.057413],[43.433298,-21.336475],[43.893683,-21.163307],[43.89637,-20.830459],[44.374325,-20.072366],[44.464397,-19.435454],[44.232422,-18.961995],[44.042976,-18.331387],[43.963084,-17.409945],[44.312469,-16.850496],[44.446517,-16.216219],[44.944937,-16.179374],[45.502732,-15.974373],[45.872994,-15.793454],[46.312243,-15.780018],[46.882183,-15.210182],[47.70513,-14.594303],[48.005215,-14.091233],[47.869047,-13.663869],[48.293828,-13.784068],[48.84506,-13.089175],[48.863509,-12.487868],[49.194651,-12.040557],[49.543519,-12.469833],[49.808981,-12.895285],[50.056511,-13.555761]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[113.338953,-39.035757,153.569469,-10.668186],"geometry":{"type":"Polygon","coordinates":[[[143.561811,-13.763656],[143.922099,-14.548311],[144.563714,-14.171176],[144.894908,-14.594458],[145.374724,-14.984976],[145.271991,-15.428205],[145.48526,-16.285672],[145.637033,-16.784918],[145.888904,-16.906926],[146.160309,-17.761655],[146.063674,-18.280073],[146.387478,-18.958274],[147.471082,-19.480723],[148.177602,-19.955939],[148.848414,-20.39121],[148.717465,-20.633469],[149.28942,-21.260511],[149.678337,-22.342512],[150.077382,-22.122784],[150.482939,-22.556142],[150.727265,-22.402405],[150.899554,-23.462237],[151.609175,-24.076256],[152.07354,-24.457887],[152.855197,-25.267501],[153.136162,-26.071173],[153.161949,-26.641319],[153.092909,-27.2603],[153.569469,-28.110067],[153.512108,-28.995077],[153.339095,-29.458202],[153.069241,-30.35024],[153.089602,-30.923642],[152.891578,-31.640446],[152.450002,-32.550003],[151.709117,-33.041342],[151.343972,-33.816023],[151.010555,-34.31036],[150.714139,-35.17346],[150.32822,-35.671879],[150.075212,-36.420206],[149.946124,-37.109052],[149.997284,-37.425261],[149.423882,-37.772681],[148.304622,-37.809061],[147.381733,-38.219217],[146.922123,-38.606532],[146.317922,-39.035757],[145.489652,-38.593768],[144.876976,-38.417448],[145.032212,-37.896188],[144.485682,-38.085324],[143.609974,-38.809465],[142.745427,-38.538268],[142.17833,-38.380034],[141.606582,-38.308514],[140.638579,-38.019333],[139.992158,-37.402936],[139.806588,-36.643603],[139.574148,-36.138362],[139.082808,-35.732754],[138.120748,-35.612296],[138.449462,-35.127261],[138.207564,-34.384723],[137.71917,-35.076825],[136.829406,-35.260535],[137.352371,-34.707339],[137.503886,-34.130268],[137.890116,-33.640479],[137.810328,-32.900007],[136.996837,-33.752771],[136.372069,-34.094766],[135.989043,-34.890118],[135.208213,-34.47867],[135.239218,-33.947953],[134.613417,-33.222778],[134.085904,-32.848072],[134.273903,-32.617234],[132.990777,-32.011224],[132.288081,-31.982647],[131.326331,-31.495803],[129.535794,-31.590423],[128.240938,-31.948489],[127.102867,-32.282267],[126.148714,-32.215966],[125.088623,-32.728751],[124.221648,-32.959487],[124.028947,-33.483847],[123.659667,-33.890179],[122.811036,-33.914467],[122.183064,-34.003402],[121.299191,-33.821036],[120.580268,-33.930177],[119.893695,-33.976065],[119.298899,-34.509366],[119.007341,-34.464149],[118.505718,-34.746819],[118.024972,-35.064733],[117.295507,-35.025459],[116.625109,-35.025097],[115.564347,-34.386428],[115.026809,-34.196517],[115.048616,-33.623425],[115.545123,-33.487258],[115.714674,-33.259572],[115.679379,-32.900369],[115.801645,-32.205062],[115.689611,-31.612437],[115.160909,-30.601594],[114.997043,-30.030725],[115.040038,-29.461095],[114.641974,-28.810231],[114.616498,-28.516399],[114.173579,-28.118077],[114.048884,-27.334765],[113.477498,-26.543134],[113.338953,-26.116545],[113.778358,-26.549025],[113.440962,-25.621278],[113.936901,-25.911235],[114.232852,-26.298446],[114.216161,-25.786281],[113.721255,-24.998939],[113.625344,-24.683971],[113.393523,-24.384764],[113.502044,-23.80635],[113.706993,-23.560215],[113.843418,-23.059987],[113.736552,-22.475475],[114.149756,-21.755881],[114.225307,-22.517488],[114.647762,-21.82952],[115.460167,-21.495173],[115.947373,-21.068688],[116.711615,-20.701682],[117.166316,-20.623599],[117.441545,-20.746899],[118.229559,-20.374208],[118.836085,-20.263311],[118.987807,-20.044203],[119.252494,-19.952942],[119.805225,-19.976506],[120.85622,-19.683708],[121.399856,-19.239756],[121.655138,-18.705318],[122.241665,-18.197649],[122.286624,-17.798603],[122.312772,-17.254967],[123.012574,-16.4052],[123.433789,-17.268558],[123.859345,-17.069035],[123.503242,-16.596506],[123.817073,-16.111316],[124.258287,-16.327944],[124.379726,-15.56706],[124.926153,-15.0751],[125.167275,-14.680396],[125.670087,-14.51007],[125.685796,-14.230656],[126.125149,-14.347341],[126.142823,-14.095987],[126.582589,-13.952791],[127.065867,-13.817968],[127.804633,-14.276906],[128.35969,-14.86917],[128.985543,-14.875991],[129.621473,-14.969784],[129.4096,-14.42067],[129.888641,-13.618703],[130.339466,-13.357376],[130.183506,-13.10752],[130.617795,-12.536392],[131.223495,-12.183649],[131.735091,-12.302453],[132.575298,-12.114041],[132.557212,-11.603012],[131.824698,-11.273782],[132.357224,-11.128519],[133.019561,-11.376411],[133.550846,-11.786515],[134.393068,-12.042365],[134.678632,-11.941183],[135.298491,-12.248606],[135.882693,-11.962267],[136.258381,-12.049342],[136.492475,-11.857209],[136.95162,-12.351959],[136.685125,-12.887223],[136.305407,-13.29123],[135.961758,-13.324509],[136.077617,-13.724278],[135.783836,-14.223989],[135.428664,-14.715432],[135.500184,-14.997741],[136.295175,-15.550265],[137.06536,-15.870762],[137.580471,-16.215082],[138.303217,-16.807604],[138.585164,-16.806622],[139.108543,-17.062679],[139.260575,-17.371601],[140.215245,-17.710805],[140.875463,-17.369069],[141.07111,-16.832047],[141.274095,-16.38887],[141.398222,-15.840532],[141.702183,-15.044921],[141.56338,-14.561333],[141.63552,-14.270395],[141.519869,-13.698078],[141.65092,-12.944688],[141.842691,-12.741548],[141.68699,-12.407614],[141.928629,-11.877466],[142.118488,-11.328042],[142.143706,-11.042737],[142.51526,-10.668186],[142.79731,-11.157355],[142.866763,-11.784707],[143.115947,-11.90563],[143.158632,-12.325656],[143.522124,-12.834358],[143.597158,-13.400422],[143.561811,-13.763656]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[161.319797,-10.826367,162.398646,-10.204751],"geometry":{"type":"Polygon","coordinates":[[[162.119025,-10.482719],[162.398646,-10.826367],[161.700032,-10.820011],[161.319797,-10.204751],[161.917383,-10.446701],[162.119025,-10.482719]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[118.967808,-10.25865,120.775502,-9.36134],"geometry":{"type":"Polygon","coordinates":[[[120.715609,-10.239581],[120.295014,-10.25865],[118.967808,-9.557969],[119.90031,-9.36134],[120.425756,-9.665921],[120.775502,-9.969675],[120.715609,-10.239581]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[159.640003,-9.89521,160.852229,-9.24295],"geometry":{"type":"Polygon","coordinates":[[[160.852229,-9.872937],[160.462588,-9.89521],[159.849447,-9.794027],[159.640003,-9.63998],[159.702945,-9.24295],[160.362956,-9.400304],[160.688518,-9.610162],[160.852229,-9.872937]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[160.579997,-9.784312,161.679982,-8.320009],"geometry":{"type":"Polygon","coordinates":[[[161.679982,-9.599982],[161.529397,-9.784312],[160.788253,-8.917543],[160.579997,-8.320009],[160.920028,-8.320009],[161.280006,-9.120011],[161.679982,-9.599982]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[123.459989,-10.359987,127.335928,-8.273345],"geometry":{"type":"Polygon","coordinates":[[[124.43595,-10.140001],[123.579982,-10.359987],[123.459989,-10.239995],[123.550009,-9.900016],[123.980009,-9.290027],[124.968682,-8.89279],[125.086246,-8.656887],[125.947072,-8.432095],[126.644704,-8.398247],[126.957243,-8.273345],[127.335928,-8.397317],[126.967992,-8.668256],[125.925885,-9.106007],[125.08852,-9.393173],[124.43595,-10.140001]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[116.740141,-9.040895,119.126507,-8.095681],"geometry":{"type":"Polygon","coordinates":[[[117.900018,-8.095681],[118.260616,-8.362383],[118.87846,-8.280683],[119.126507,-8.705825],[117.970402,-8.906639],[117.277731,-9.040895],[116.740141,-9.032937],[117.083737,-8.457158],[117.632024,-8.449303],[117.900018,-8.095681]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[119.920929,-8.933666,122.903537,-8.094234],"geometry":{"type":"Polygon","coordinates":[[[122.903537,-8.094234],[122.756983,-8.649808],[121.254491,-8.933666],[119.924391,-8.810418],[119.920929,-8.444859],[120.715092,-8.236965],[121.341669,-8.53674],[122.007365,-8.46062],[122.903537,-8.094234]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[158.21115,-8.53829,159.917402,-7.320018],"geometry":{"type":"Polygon","coordinates":[[[159.875027,-8.33732],[159.917402,-8.53829],[159.133677,-8.114181],[158.586114,-7.754824],[158.21115,-7.421872],[158.359978,-7.320018],[158.820001,-7.560003],[159.640003,-8.020027],[159.875027,-8.33732]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[156.491358,-7.404767,157.538426,-6.599338],"geometry":{"type":"Polygon","coordinates":[[[157.538426,-7.34782],[157.33942,-7.404767],[156.90203,-7.176874],[156.491358,-6.765943],[156.542828,-6.599338],[157.14,-7.021638],[157.538426,-7.34782]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[105.365486,-8.751817,115.705527,-5.895919],"geometry":{"type":"Polygon","coordinates":[[[108.623479,-6.777674],[110.539227,-6.877358],[110.759576,-6.465186],[112.614811,-6.946036],[112.978768,-7.594213],[114.478935,-7.776528],[115.705527,-8.370807],[114.564511,-8.751817],[113.464734,-8.348947],[112.559672,-8.376181],[111.522061,-8.302129],[110.58615,-8.122605],[109.427667,-7.740664],[108.693655,-7.6416],[108.277763,-7.766657],[106.454102,-7.3549],[106.280624,-6.9249],[105.365486,-6.851416],[106.051646,-5.895919],[107.265009,-5.954985],[108.072091,-6.345762],[108.486846,-6.421985],[108.623479,-6.777674]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[134.112776,-6.895238,134.727002,-5.445042],"geometry":{"type":"Polygon","coordinates":[[[134.724624,-6.214401],[134.210134,-6.895238],[134.112776,-6.142467],[134.290336,-5.783058],[134.499625,-5.445042],[134.727002,-5.737582],[134.724624,-6.214401]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[154.514114,-6.919991,156.019965,-5.042431],"geometry":{"type":"Polygon","coordinates":[[[155.880026,-6.819997],[155.599991,-6.919991],[155.166994,-6.535931],[154.729192,-5.900828],[154.514114,-5.139118],[154.652504,-5.042431],[154.759991,-5.339984],[155.062918,-5.566792],[155.547746,-6.200655],[156.019965,-6.540014],[155.880026,-6.819997]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[148.318937,-6.317754,152.338743,-4.14879],"geometry":{"type":"Polygon","coordinates":[[[151.982796,-5.478063],[151.459107,-5.56028],[151.30139,-5.840728],[150.754447,-6.083763],[150.241197,-6.317754],[149.709963,-6.316513],[148.890065,-6.02604],[148.318937,-5.747142],[148.401826,-5.437756],[149.298412,-5.583742],[149.845562,-5.505503],[149.99625,-5.026101],[150.139756,-5.001348],[150.236908,-5.53222],[150.807467,-5.455842],[151.089672,-5.113693],[151.647881,-4.757074],[151.537862,-4.167807],[152.136792,-4.14879],[152.338743,-4.312966],[152.318693,-4.867661],[151.982796,-5.478063]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[125.989034,-3.790983,127.249215,-3.129318],"geometry":{"type":"Polygon","coordinates":[[[127.249215,-3.459065],[126.874923,-3.790983],[126.183802,-3.607376],[125.989034,-3.177273],[127.000651,-3.129318],[127.249215,-3.459065]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[127.898891,-3.858472,130.834836,-2.802154],"geometry":{"type":"Polygon","coordinates":[[[130.471344,-3.093764],[130.834836,-3.858472],[129.990547,-3.446301],[129.155249,-3.362637],[128.590684,-3.428679],[127.898891,-3.393436],[128.135879,-2.84365],[129.370998,-2.802154],[130.471344,-3.093764]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[150.66205,-4.766427,153.140038,-2.500002],"geometry":{"type":"Polygon","coordinates":[[[153.140038,-4.499983],[152.827292,-4.766427],[152.638673,-4.176127],[152.406026,-3.789743],[151.953237,-3.462062],[151.384279,-3.035422],[150.66205,-2.741486],[150.939965,-2.500002],[151.479984,-2.779985],[151.820015,-2.999972],[152.239989,-3.240009],[152.640017,-3.659983],[153.019994,-3.980015],[153.140038,-4.499983]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[130.519558,-10.652476,150.801628,-0.369538],"geometry":{"type":"Polygon","coordinates":[[[134.143368,-1.151867],[134.422627,-2.769185],[135.457603,-3.367753],[136.293314,-2.307042],[137.440738,-1.703513],[138.329727,-1.702686],[139.184921,-2.051296],[139.926684,-2.409052],[141.00021,-2.600151],[142.735247,-3.289153],[144.583971,-3.861418],[145.27318,-4.373738],[145.829786,-4.876498],[145.981922,-5.465609],[147.648073,-6.083659],[147.891108,-6.614015],[146.970905,-6.721657],[147.191874,-7.388024],[148.084636,-8.044108],[148.734105,-9.104664],[149.306835,-9.071436],[149.266631,-9.514406],[150.038728,-9.684318],[149.738798,-9.872937],[150.801628,-10.293687],[150.690575,-10.582713],[150.028393,-10.652476],[149.78231,-10.393267],[148.923138,-10.280923],[147.913018,-10.130441],[147.135443,-9.492444],[146.567881,-8.942555],[146.048481,-8.067414],[144.744168,-7.630128],[143.897088,-7.91533],[143.286376,-8.245491],[143.413913,-8.983069],[142.628431,-9.326821],[142.068259,-9.159596],[141.033852,-9.117893],[140.143415,-8.297168],[139.127767,-8.096043],[138.881477,-8.380935],[137.614474,-8.411683],[138.039099,-7.597882],[138.668621,-7.320225],[138.407914,-6.232849],[137.92784,-5.393366],[135.98925,-4.546544],[135.164598,-4.462931],[133.66288,-3.538853],[133.367705,-4.024819],[132.983956,-4.112979],[132.756941,-3.746283],[132.753789,-3.311787],[131.989804,-2.820551],[133.066845,-2.460418],[133.780031,-2.479848],[133.696212,-2.214542],[132.232373,-2.212526],[131.836222,-1.617162],[130.94284,-1.432522],[130.519558,-0.93772],[131.867538,-0.695461],[132.380116,-0.369538],[133.985548,-0.78021],[134.143368,-1.151867]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[118.767769,-5.6734,125.240501,1.643259],"geometry":{"type":"Polygon","coordinates":[[[125.240501,1.419836],[124.437035,0.427881],[123.685505,0.235593],[122.723083,0.431137],[121.056725,0.381217],[120.183083,0.237247],[120.04087,-0.519658],[120.935905,-1.408906],[121.475821,-0.955962],[123.340565,-0.615673],[123.258399,-1.076213],[122.822715,-0.930951],[122.38853,-1.516858],[121.508274,-1.904483],[122.454572,-3.186058],[122.271896,-3.5295],[123.170963,-4.683693],[123.162333,-5.340604],[122.628515,-5.634591],[122.236394,-5.282933],[122.719569,-4.464172],[121.738234,-4.851331],[121.489463,-4.574553],[121.619171,-4.188478],[120.898182,-3.602105],[120.972389,-2.627643],[120.305453,-2.931604],[120.390047,-4.097579],[120.430717,-5.528241],[119.796543,-5.6734],[119.366906,-5.379878],[119.653606,-4.459417],[119.498835,-3.494412],[119.078344,-3.487022],[118.767769,-2.801999],[119.180974,-2.147104],[119.323394,-1.353147],[119.825999,0.154254],[120.035702,0.566477],[120.885779,1.309223],[121.666817,1.013944],[122.927567,0.875192],[124.077522,0.917102],[125.065989,1.643259],[125.240501,1.419836]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[127.39949,-0.899996,128.688249,2.174596],"geometry":{"type":"Polygon","coordinates":[[[128.688249,1.132386],[128.635952,0.258486],[128.12017,0.356413],[127.968034,-0.252077],[128.379999,-0.780004],[128.100016,-0.899996],[127.696475,-0.266598],[127.39949,1.011722],[127.600512,1.810691],[127.932378,2.174596],[128.004156,1.628531],[128.594559,1.540811],[128.688249,1.132386]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[95.293026,-5.873285,106.108593,5.479821],"geometry":{"type":"Polygon","coordinates":[[[105.817655,-5.852356],[104.710384,-5.873285],[103.868213,-5.037315],[102.584261,-4.220259],[102.156173,-3.614146],[101.399113,-2.799777],[100.902503,-2.050262],[100.141981,-0.650348],[99.26374,0.183142],[98.970011,1.042882],[98.601351,1.823507],[97.699598,2.453184],[97.176942,3.308791],[96.424017,3.86886],[95.380876,4.970782],[95.293026,5.479821],[95.936863,5.439513],[97.484882,5.246321],[98.369169,4.26837],[99.142559,3.59035],[99.693998,3.174329],[100.641434,2.099381],[101.658012,2.083697],[102.498271,1.3987],[103.07684,0.561361],[103.838396,0.104542],[103.437645,-0.711946],[104.010789,-1.059212],[104.369991,-1.084843],[104.53949,-1.782372],[104.887893,-2.340425],[105.622111,-2.428844],[106.108593,-3.061777],[105.857446,-4.305525],[105.817655,-5.852356]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[108.952658,-4.106984,119.181904,6.928053],"geometry":{"type":"Polygon","coordinates":[[[117.875627,1.827641],[118.996747,0.902219],[117.811858,0.784242],[117.478339,0.102475],[117.521644,-0.803723],[116.560048,-1.487661],[116.533797,-2.483517],[116.148084,-4.012726],[116.000858,-3.657037],[114.864803,-4.106984],[114.468652,-3.495704],[113.755672,-3.43917],[113.256994,-3.118776],[112.068126,-3.478392],[111.703291,-2.994442],[111.04824,-3.049426],[110.223846,-2.934032],[110.070936,-1.592874],[109.571948,-1.314907],[109.091874,-0.459507],[108.952658,0.415375],[109.069136,1.341934],[109.66326,2.006467],[110.396135,1.663775],[111.168853,1.850637],[111.370081,2.697303],[111.796928,2.885897],[112.995615,3.102395],[113.712935,3.893509],[114.204017,4.525874],[114.599961,4.900011],[115.45071,5.44773],[116.220741,6.143191],[116.725103,6.924771],[117.129626,6.928053],[117.643393,6.422166],[117.689075,5.98749],[118.347691,5.708696],[119.181904,5.407836],[119.110694,5.016128],[118.439727,4.966519],[118.618321,4.478202],[117.882035,4.137551],[117.313232,3.234428],[118.04833,2.28769],[117.875627,1.827641]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[121.919928,5.581003,126.537424,9.760335],"geometry":{"type":"Polygon","coordinates":[[[126.376814,8.414706],[126.478513,7.750354],[126.537424,7.189381],[126.196773,6.274294],[125.831421,7.293715],[125.363852,6.786485],[125.683161,6.049657],[125.396512,5.581003],[124.219788,6.161355],[123.93872,6.885136],[124.243662,7.36061],[123.610212,7.833527],[123.296071,7.418876],[122.825506,7.457375],[122.085499,6.899424],[121.919928,7.192119],[122.312359,8.034962],[122.942398,8.316237],[123.487688,8.69301],[123.841154,8.240324],[124.60147,8.514158],[124.764612,8.960409],[125.471391,8.986997],[125.412118,9.760335],[126.222714,9.286074],[126.306637,8.782487],[126.376814,8.414706]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[79.695167,5.96837,81.787959,9.824078],"geometry":{"type":"Polygon","coordinates":[[[81.21802,6.197141],[80.348357,5.96837],[79.872469,6.763463],[79.695167,8.200843],[80.147801,9.824078],[80.838818,9.268427],[81.304319,8.564206],[81.787959,7.523055],[81.637322,6.481775],[81.21802,6.197141]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[-61.95,10,-60.895,10.89],"geometry":{"type":"Polygon","coordinates":[[[-60.935,10.11],[-61.77,10],[-61.95,10.09],[-61.66,10.365],[-61.68,10.76],[-61.105,10.89],[-60.895,10.855],[-60.935,10.11]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[122.380055,9.022189,124.077936,11.232726],"geometry":{"type":"Polygon","coordinates":[[[123.982438,10.278779],[123.623183,9.950091],[123.309921,9.318269],[122.995883,9.022189],[122.380055,9.713361],[122.586089,9.981045],[122.837081,10.261157],[122.947411,10.881868],[123.49885,10.940624],[123.337774,10.267384],[124.077936,11.232726],[123.982438,10.278779]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[117.174275,8.3675,119.689677,11.369668],"geometry":{"type":"Polygon","coordinates":[[[118.504581,9.316383],[117.174275,8.3675],[117.664477,9.066889],[118.386914,9.6845],[118.987342,10.376292],[119.511496,11.369668],[119.689677,10.554291],[119.029458,10.003653],[118.504581,9.316383]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[121.883548,10.441017,123.120217,11.891755],"geometry":{"type":"Polygon","coordinates":[[[121.883548,11.891755],[122.483821,11.582187],[123.120217,11.58366],[123.100838,11.165934],[122.637714,10.741308],[122.00261,10.441017],[121.967367,10.905691],[122.03837,11.415841],[121.883548,11.891755]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[124.266762,10.134679,125.783465,12.557761],"geometry":{"type":"Polygon","coordinates":[[[125.502552,12.162695],[125.783465,11.046122],[125.011884,11.311455],[125.032761,10.975816],[125.277449,10.358722],[124.801819,10.134679],[124.760168,10.837995],[124.459101,10.88993],[124.302522,11.495371],[124.891013,11.415583],[124.87799,11.79419],[124.266762,12.557761],[125.227116,12.535721],[125.502552,12.162695]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[120.323436,12.20556,121.527394,13.466413],"geometry":{"type":"Polygon","coordinates":[[[121.527394,13.06959],[121.26219,12.20556],[120.833896,12.704496],[120.323436,13.466413],[121.180128,13.429697],[121.527394,13.06959]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[119.883773,12.536677,124.181289,18.505227],"geometry":{"type":"Polygon","coordinates":[[[121.321308,18.504065],[121.937601,18.218552],[122.246006,18.47895],[122.336957,18.224883],[122.174279,17.810283],[122.515654,17.093505],[122.252311,16.262444],[121.662786,15.931018],[121.50507,15.124814],[121.728829,14.328376],[122.258925,14.218202],[122.701276,14.336541],[123.950295,13.782131],[123.855107,13.237771],[124.181289,12.997527],[124.077419,12.536677],[123.298035,13.027526],[122.928652,13.55292],[122.671355,13.185836],[122.03465,13.784482],[121.126385,13.636687],[120.628637,13.857656],[120.679384,14.271016],[120.991819,14.525393],[120.693336,14.756671],[120.564145,14.396279],[120.070429,14.970869],[119.920929,15.406347],[119.883773,16.363704],[120.286488,16.034629],[120.390047,17.599081],[120.715867,18.505227],[121.321308,18.504065]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-67.242428,17.946553,-65.591004,18.520601],"geometry":{"type":"Polygon","coordinates":[[[-65.591004,18.228035],[-65.847164,17.975906],[-66.599934,17.981823],[-67.184162,17.946553],[-67.242428,18.37446],[-67.100679,18.520601],[-66.282434,18.514762],[-65.771303,18.426679],[-65.591004,18.228035]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-78.337719,17.701116,-76.199659,18.524218],"geometry":{"type":"Polygon","coordinates":[[[-76.902561,17.868238],[-77.206341,17.701116],[-77.766023,17.861597],[-78.337719,18.225968],[-78.217727,18.454533],[-77.797365,18.524218],[-77.569601,18.490525],[-76.896619,18.400867],[-76.365359,18.160701],[-76.199659,17.886867],[-76.902561,17.868238]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-74.458034,17.598564,-68.317943,19.915684],"geometry":{"type":"Polygon","coordinates":[[[-72.579673,19.871501],[-71.712361,19.714456],[-71.587304,19.884911],[-70.806706,19.880286],[-70.214365,19.622885],[-69.950815,19.648],[-69.76925,19.293267],[-69.222126,19.313214],[-69.254346,19.015196],[-68.809412,18.979074],[-68.317943,18.612198],[-68.689316,18.205142],[-69.164946,18.422648],[-69.623988,18.380713],[-69.952934,18.428307],[-70.133233,18.245915],[-70.517137,18.184291],[-70.669298,18.426886],[-70.99995,18.283329],[-71.40021,17.598564],[-71.657662,17.757573],[-71.708305,18.044997],[-72.372476,18.214961],[-72.844411,18.145611],[-73.454555,18.217906],[-73.922433,18.030993],[-74.458034,18.34255],[-74.369925,18.664908],[-73.449542,18.526053],[-72.694937,18.445799],[-72.334882,18.668422],[-72.79165,19.101625],[-72.784105,19.483591],[-73.415022,19.639551],[-73.189791,19.915684],[-72.579673,19.871501]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[108.626217,18.197701,111.010051,20.101254],"geometry":{"type":"Polygon","coordinates":[[[110.339188,18.678395],[109.47521,18.197701],[108.655208,18.507682],[108.626217,19.367888],[109.119056,19.821039],[110.211599,20.101254],[110.786551,20.077534],[111.010051,19.69593],[110.570647,19.255879],[110.339188,18.678395]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0.5},"bbox":[-156.07347,18.91619,-154.80741,20.26721],"geometry":{"type":"Polygon","coordinates":[[[-155.54211,19.08348],[-155.68817,18.91619],[-155.93665,19.05939],[-155.90806,19.33888],[-156.07347,19.70294],[-156.02368,19.81422],[-155.85008,19.97729],[-155.91907,20.17395],[-155.86108,20.26721],[-155.78505,20.2487],[-155.40214,20.07975],[-155.22452,19.99302],[-155.06226,19.8591],[-154.80741,19.50871],[-154.83147,19.45328],[-155.22217,19.23972],[-155.54211,19.08348]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[-156.71055,20.57241,-155.99566,21.01249],"geometry":{"type":"Polygon","coordinates":[[[-156.07926,20.64397],[-156.41445,20.57241],[-156.58673,20.783],[-156.70167,20.8643],[-156.71055,20.92676],[-156.61258,21.01249],[-156.25711,20.91745],[-155.99566,20.76404],[-156.07926,20.64397]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[-157.32521,21.06873,-156.75824,21.21958],"geometry":{"type":"Polygon","coordinates":[[[-156.75824,21.17684],[-156.78933,21.06873],[-157.32521,21.09777],[-157.25027,21.21958],[-156.75824,21.17684]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[-158.29265,21.26442,-157.65283,21.71696],"geometry":{"type":"Polygon","coordinates":[[[-157.65283,21.32217],[-157.70703,21.26442],[-157.7786,21.27729],[-158.12667,21.31244],[-158.2538,21.53919],[-158.29265,21.57912],[-158.0252,21.71696],[-157.94161,21.65272],[-157.65283,21.32217]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[-159.80051,21.88299,-159.34512,22.23618],"geometry":{"type":"Polygon","coordinates":[[[-159.34512,21.982],[-159.46372,21.88299],[-159.80051,22.06533],[-159.74877,22.1382],[-159.5962,22.23618],[-159.36569,22.21494],[-159.34512,21.982]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-84.974911,19.855481,-74.178025,23.188611],"geometry":{"type":"Polygon","coordinates":[[[-79.679524,22.765303],[-79.281486,22.399202],[-78.347434,22.512166],[-77.993296,22.277194],[-77.146422,21.657851],[-76.523825,21.20682],[-76.19462,21.220565],[-75.598222,21.016624],[-75.67106,20.735091],[-74.933896,20.693905],[-74.178025,20.284628],[-74.296648,20.050379],[-74.961595,19.923435],[-75.63468,19.873774],[-76.323656,19.952891],[-77.755481,19.855481],[-77.085108,20.413354],[-77.492655,20.673105],[-78.137292,20.739949],[-78.482827,21.028613],[-78.719867,21.598114],[-79.285,21.559175],[-80.217475,21.827324],[-80.517535,22.037079],[-81.820943,22.192057],[-82.169992,22.387109],[-81.795002,22.636965],[-82.775898,22.68815],[-83.494459,22.168518],[-83.9088,22.154565],[-84.052151,21.910575],[-84.54703,21.801228],[-84.974911,21.896028],[-84.447062,22.20495],[-84.230357,22.565755],[-83.77824,22.788118],[-83.267548,22.983042],[-82.510436,23.078747],[-82.268151,23.188611],[-81.404457,23.117271],[-80.618769,23.10598],[-79.679524,22.765303]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[-78.40848,23.71,-77.53466,25.2103],"geometry":{"type":"Polygon","coordinates":[[[-77.53466,23.75975],[-77.78,23.71],[-78.03405,24.28615],[-78.40848,24.57564],[-78.19087,25.2103],[-77.89,25.17],[-77.54,24.34],[-77.53466,23.75975]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[120.106189,21.970571,121.951244,25.295459],"geometry":{"type":"Polygon","coordinates":[[[121.175632,22.790857],[120.74708,21.970571],[120.220083,22.814861],[120.106189,23.556263],[120.69468,24.538451],[121.495044,25.295459],[121.951244,24.997596],[121.777818,24.394274],[121.175632,22.790857]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[-78.98,26.42,-77.82,26.87],"geometry":{"type":"Polygon","coordinates":[[[-77.82,26.58],[-78.91,26.42],[-78.98,26.79],[-78.51,26.87],[-77.85,26.84],[-77.82,26.58]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[-77.79,25.87918,-77,27.04],"geometry":{"type":"Polygon","coordinates":[[[-77,26.59],[-77.17255,25.87918],[-77.35641,26.00735],[-77.34,26.53],[-77.78802,26.92516],[-77.79,27.04],[-77,26.59]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[132.363115,32.704567,134.766379,34.364931],"geometry":{"type":"Polygon","coordinates":[[[134.638428,34.149234],[134.766379,33.806335],[134.203416,33.201178],[133.79295,33.521985],[133.280268,33.28957],[133.014858,32.704567],[132.363115,32.989382],[132.371176,33.463642],[132.924373,34.060299],[133.492968,33.944621],[133.904106,34.364931],[134.638428,34.149234]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[32.256667,34.571869,34.576474,35.671596],"geometry":{"type":"Polygon","coordinates":[[[34.576474,35.671596],[33.900804,35.245756],[33.973617,35.058506],[34.004881,34.978098],[32.979827,34.571869],[32.490296,34.701655],[32.256667,35.103232],[32.73178,35.140026],[32.802474,35.145504],[32.946961,35.386703],[33.667227,35.373216],[34.576474,35.671596]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[23.514978,34.919988,26.290003,35.705004],"geometry":{"type":"Polygon","coordinates":[[[23.69998,35.705004],[24.246665,35.368022],[25.025015,35.424996],[25.769208,35.354018],[25.745023,35.179998],[26.290003,35.29999],[26.164998,35.004995],[24.724982,34.919988],[24.735007,35.084991],[23.514978,35.279992],[23.69998,35.705004]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[12.431004,36.619987,15.520376,38.231155],"geometry":{"type":"Polygon","coordinates":[[[15.520376,38.231155],[15.160243,37.444046],[15.309898,37.134219],[15.099988,36.619987],[14.335229,36.996631],[13.826733,37.104531],[12.431004,37.61295],[12.570944,38.126381],[13.741156,38.034966],[14.761249,38.143874],[15.520376,38.231155]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[8.159998,38.906618,9.809975,41.209991],"geometry":{"type":"Polygon","coordinates":[[[9.210012,41.209991],[9.809975,40.500009],[9.669519,39.177376],[9.214818,39.240473],[8.806936,38.906618],[8.428302,39.171847],[8.388253,40.378311],[8.159998,40.950007],[8.709991,40.899984],[9.210012,41.209991]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[129.408463,31.029579,141.914263,41.37856],"geometry":{"type":"Polygon","coordinates":[[[140.976388,37.142074],[140.59977,36.343983],[140.774074,35.842877],[140.253279,35.138114],[138.975528,34.6676],[137.217599,34.606286],[135.792983,33.464805],[135.120983,33.849071],[135.079435,34.596545],[133.340316,34.375938],[132.156771,33.904933],[130.986145,33.885761],[132.000036,33.149992],[131.33279,31.450355],[130.686318,31.029579],[130.20242,31.418238],[130.447676,32.319475],[129.814692,32.61031],[129.408463,33.296056],[130.353935,33.604151],[130.878451,34.232743],[131.884229,34.749714],[132.617673,35.433393],[134.608301,35.731618],[135.677538,35.527134],[136.723831,37.304984],[137.390612,36.827391],[138.857602,37.827485],[139.426405,38.215962],[140.05479,39.438807],[139.883379,40.563312],[140.305783,41.195005],[141.368973,41.37856],[141.914263,39.991616],[141.884601,39.180865],[140.959489,38.174001],[140.976388,37.142074]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[8.544213,41.380007,9.560016,43.009985],"geometry":{"type":"Polygon","coordinates":[[[9.560016,42.152492],[9.229752,41.380007],[8.775723,41.583612],[8.544213,42.256517],[8.746009,42.628122],[9.390001,43.009985],[9.560016,42.152492]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[139.817544,41.569556,145.543137,45.551483],"geometry":{"type":"Polygon","coordinates":[[[143.910162,44.1741],[144.613427,43.960883],[145.320825,44.384733],[145.543137,43.262088],[144.059662,42.988358],[143.18385,41.995215],[141.611491,42.678791],[141.067286,41.584594],[139.955106,41.569556],[139.817544,42.563759],[140.312087,43.333273],[141.380549,43.388825],[141.671952,44.772125],[141.967645,45.551483],[143.14287,44.510358],[143.910162,44.1741]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[-64.39261,45.96818,-62.01208,47.03601],"geometry":{"type":"Polygon","coordinates":[[[-63.6645,46.55001],[-62.9393,46.41587],[-62.01208,46.44314],[-62.50391,46.03339],[-62.87433,45.96818],[-64.1428,46.39265],[-64.39261,46.72747],[-64.01486,47.03601],[-63.6645,46.55001]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[-64.51912,49.08717,-61.806305,49.95718],"geometry":{"type":"Polygon","coordinates":[[[-61.806305,49.10506],[-62.29318,49.08717],[-63.58926,49.40069],[-64.51912,49.87304],[-64.17322,49.95718],[-62.85829,49.70641],[-61.835585,49.28855],[-61.806305,49.10506]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-128.444584,48.370846,-123.510002,50.770648],"geometry":{"type":"Polygon","coordinates":[[[-123.510002,48.510011],[-124.012891,48.370846],[-125.655013,48.825005],[-125.954994,49.179996],[-126.850004,49.53],[-127.029993,49.814996],[-128.059336,49.994959],[-128.444584,50.539138],[-128.358414,50.770648],[-127.308581,50.552574],[-126.695001,50.400903],[-125.755007,50.295018],[-125.415002,49.950001],[-124.920768,49.475275],[-123.922509,49.062484],[-123.510002,48.510011]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-59.419494,46.618292,-52.648099,51.632094],"geometry":{"type":"Polygon","coordinates":[[[-56.134036,50.68701],[-56.795882,49.812309],[-56.143105,50.150117],[-55.471492,49.935815],[-55.822401,49.587129],[-54.935143,49.313011],[-54.473775,49.556691],[-53.476549,49.249139],[-53.786014,48.516781],[-53.086134,48.687804],[-52.958648,48.157164],[-52.648099,47.535548],[-53.069158,46.655499],[-53.521456,46.618292],[-54.178936,46.807066],[-53.961869,47.625207],[-54.240482,47.752279],[-55.400773,46.884994],[-55.997481,46.91972],[-55.291219,47.389562],[-56.250799,47.632545],[-57.325229,47.572807],[-59.266015,47.603348],[-59.419494,47.899454],[-58.796586,48.251525],[-59.231625,48.523188],[-58.391805,49.125581],[-57.35869,50.718274],[-56.73865,51.287438],[-55.870977,51.632094],[-55.406974,51.588273],[-55.600218,51.317075],[-56.134036,50.68701]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-133.239664,52.180433,-131.179043,54.169975],"geometry":{"type":"Polygon","coordinates":[[[-132.710008,54.040009],[-132.710009,54.040009],[-132.710008,54.040009],[-132.710008,54.040009],[-131.74999,54.120004],[-132.04948,52.984621],[-131.179043,52.180433],[-131.57783,52.182371],[-132.180428,52.639707],[-132.549992,53.100015],[-133.054611,53.411469],[-133.239664,53.85108],[-133.180004,54.169975],[-132.710008,54.040009]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[141.594076,45.966755,144.654148,54.365881],"geometry":{"type":"Polygon","coordinates":[[[143.648007,50.7476],[144.654148,48.976391],[143.173928,49.306551],[142.558668,47.861575],[143.533492,46.836728],[143.505277,46.137908],[142.747701,46.740765],[142.09203,45.966755],[141.906925,46.805929],[142.018443,47.780133],[141.904445,48.859189],[142.1358,49.615163],[142.179983,50.952342],[141.594076,51.935435],[141.682546,53.301966],[142.606934,53.762145],[142.209749,54.225476],[142.654786,54.365881],[142.914616,53.704578],[143.260848,52.74076],[143.235268,51.75666],[143.648007,50.7476]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-9.977086,51.669301,-5.661949,55.17286],"geometry":{"type":"Polygon","coordinates":[[[-6.788857,52.260118],[-8.561617,51.669301],[-9.977086,51.820455],[-9.166283,52.864629],[-9.688525,53.881363],[-8.327987,54.664519],[-7.572168,55.131622],[-6.733847,55.17286],[-5.661949,54.554603],[-6.197885,53.867565],[-6.032985,53.153164],[-6.788857,52.260118]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[10.903914,54.800015,12.690006,56.111407],"geometry":{"type":"Polygon","coordinates":[[[12.690006,55.609991],[12.089991,54.800015],[11.043543,55.364864],[10.903914,55.779955],[12.370904,56.111407],[12.690006,55.609991]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-154.670993,56.734677,-152.141147,57.968968],"geometry":{"type":"Polygon","coordinates":[[[-153.006314,57.115842],[-154.00509,56.734677],[-154.516403,56.992749],[-154.670993,57.461196],[-153.76278,57.816575],[-153.228729,57.968968],[-152.564791,57.901427],[-152.141147,57.591059],[-153.006314,57.115842]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-6.149981,49.96,1.681531,58.635],"geometry":{"type":"Polygon","coordinates":[[[-3.005005,58.635],[-4.073828,57.553025],[-3.055002,57.690019],[-1.959281,57.6848],[-2.219988,56.870017],[-3.119003,55.973793],[-2.085009,55.909998],[-1.114991,54.624986],[-0.430485,54.464376],[0.184981,53.325014],[0.469977,52.929999],[1.681531,52.73952],[1.559988,52.099998],[1.050562,51.806761],[1.449865,51.289428],[0.550334,50.765739],[-0.787517,50.774989],[-2.489998,50.500019],[-2.956274,50.69688],[-3.617448,50.228356],[-4.542508,50.341837],[-5.245023,49.96],[-5.776567,50.159678],[-4.30999,51.210001],[-3.414851,51.426009],[-4.984367,51.593466],[-5.267296,51.9914],[-4.222347,52.301356],[-4.770013,52.840005],[-4.579999,53.495004],[-3.09208,53.404441],[-2.945009,53.985],[-3.630005,54.615013],[-4.844169,54.790971],[-5.082527,55.061601],[-4.719112,55.508473],[-5.047981,55.783986],[-5.586398,55.311146],[-5.644999,56.275015],[-6.149981,56.78501],[-5.786825,57.818848],[-5.009999,58.630013],[-4.211495,58.550845],[-3.005005,58.635]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[-167.455277,59.754441,-165.579164,60.38417],"geometry":{"type":"Polygon","coordinates":[[[-165.579164,59.909987],[-166.19277,59.754441],[-166.848337,59.941406],[-167.455277,60.213069],[-166.467792,60.38417],[-165.67443,60.293607],[-165.579164,59.909987]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[-80.36215,61.63308,-79.26582,62.3856],"geometry":{"type":"Polygon","coordinates":[[[-79.26582,62.158675],[-79.65752,61.63308],[-80.09956,61.7181],[-80.36215,62.01649],[-80.315395,62.085565],[-79.92939,62.3856],[-79.52002,62.36371],[-79.26582,62.158675]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[-83.99367,62.15922,-81.87699,62.91409],"geometry":{"type":"Polygon","coordinates":[[[-81.89825,62.7108],[-83.06857,62.15922],[-83.77462,62.18231],[-83.99367,62.4528],[-83.25048,62.91409],[-81.87699,62.90458],[-81.89825,62.7108]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[-171.791111,62.976931,-168.689439,63.782515],"geometry":{"type":"Polygon","coordinates":[[[-171.731657,63.782515],[-171.114434,63.592191],[-170.491112,63.694975],[-169.682505,63.431116],[-168.689439,63.297506],[-168.771941,63.188598],[-169.52944,62.976931],[-170.290556,63.194438],[-170.671386,63.375822],[-171.553063,63.317789],[-171.791111,63.405846],[-171.731657,63.782515]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":0.5},"bbox":[-87.221983,63.052379,-80.103451,65.738778],"geometry":{"type":"Polygon","coordinates":[[[-85.161308,65.657285],[-84.975764,65.217518],[-84.464012,65.371772],[-83.882626,65.109618],[-82.787577,64.766693],[-81.642014,64.455136],[-81.55344,63.979609],[-80.817361,64.057486],[-80.103451,63.725981],[-80.99102,63.411246],[-82.547178,63.651722],[-83.108798,64.101876],[-84.100417,63.569712],[-85.523405,63.052379],[-85.866769,63.637253],[-87.221983,63.541238],[-86.35276,64.035833],[-86.224886,64.822917],[-85.883848,65.738778],[-85.161308,65.657285]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-24.326184,63.496383,-13.609732,66.526792],"geometry":{"type":"Polygon","coordinates":[[[-14.508695,66.455892],[-14.739637,65.808748],[-13.609732,65.126671],[-14.909834,64.364082],[-17.794438,63.678749],[-18.656246,63.496383],[-19.972755,63.643635],[-22.762972,63.960179],[-21.778484,64.402116],[-23.955044,64.89113],[-22.184403,65.084968],[-22.227423,65.378594],[-24.326184,65.611189],[-23.650515,66.262519],[-22.134922,66.410469],[-20.576284,65.732112],[-19.056842,66.276601],[-17.798624,65.993853],[-16.167819,66.526792],[-14.508695,66.455892]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[-77.2364,67.09873,-75.10333,68.28721],"geometry":{"type":"Polygon","coordinates":[[[-75.86588,67.14886],[-76.98687,67.09873],[-77.2364,67.58809],[-76.81166,68.14856],[-75.89521,68.28721],[-75.1145,68.01036],[-75.10333,67.58202],[-75.21597,67.44425],[-75.86588,67.14886]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-180,64.25269,-169.89958,68.963636],"geometry":{"type":"Polygon","coordinates":[[[-175.01425,66.58435],[-174.33983,66.33556],[-174.57182,67.06219],[-171.85731,66.91308],[-169.89958,65.97724],[-170.89107,65.54139],[-172.53025,65.43791],[-172.555,64.46079],[-172.95533,64.25269],[-173.89184,64.2826],[-174.65392,64.63125],[-175.98353,64.92288],[-176.20716,65.35667],[-177.22266,65.52024],[-178.35993,65.39052],[-178.90332,65.74044],[-178.68611,66.11211],[-179.88377,65.87456],[-179.43268,65.40411],[-180,64.979709],[-180,68.963636],[-177.55,68.2],[-174.92825,67.20589],[-175.01425,66.58435]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[-99.797401,68.75704,-95.647681,70.14354],"geometry":{"type":"Polygon","coordinates":[[[-95.647681,69.10769],[-96.269521,68.75704],[-97.617401,69.06003],[-98.431801,68.9507],[-99.797401,69.40003],[-98.917401,69.71003],[-98.218261,70.14354],[-97.157401,69.86003],[-96.557401,69.68003],[-96.257401,69.49003],[-95.647681,69.10769]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[178.7253,70.78114,180,71.515714],"geometry":{"type":"Polygon","coordinates":[[[180,70.832199],[178.903425,70.78114],[178.7253,71.0988],[180,71.515714],[180,70.832199]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-180,70.832199,-177.577945,71.55762],"geometry":{"type":"Polygon","coordinates":[[[-178.69378,70.89302],[-180,70.832199],[-180,71.515714],[-179.871875,71.55762],[-179.02433,71.55553],[-177.577945,71.26948],[-177.663575,71.13277],[-178.69378,70.89302]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-168.110474,-53.856384,-34.72998,71.920471],"geometry":{"type":"Polygon","coordinates":[[[-90.547119,69.497681],[-90.551514,68.475098],[-89.215088,69.258728],[-88.019592,68.615112],[-88.317505,67.873474],[-87.350098,67.19873],[-86.306091,67.921509],[-85.576599,68.784485],[-85.521912,69.88208],[-84.100769,69.805481],[-82.622498,69.658325],[-81.280396,69.162109],[-81.220215,68.66571],[-81.964294,68.132507],[-81.259277,67.59729],[-81.386475,67.110901],[-83.344482,66.411682],[-84.735413,66.257324],[-85.769409,66.558289],[-86.067627,66.056274],[-87.031372,65.213074],[-87.323181,64.775696],[-88.48291,64.099121],[-89.914429,64.032715],[-90.703979,63.610291],[-90.77002,62.960327],[-91.933411,62.835083],[-93.156982,62.024719],[-94.241516,60.898682],[-94.629272,60.110291],[-94.684509,58.948914],[-93.215027,58.782104],[-92.764587,57.845703],[-92.296997,57.087097],[-90.897705,57.284729],[-89.03949,56.851685],[-88.039795,56.47168],[-87.324219,55.999084],[-86.071228,55.723877],[-85.01178,55.302673],[-83.360474,55.244873],[-82.272827,55.148315],[-82.436218,54.282288],[-82.125,53.2771],[-81.400696,52.157898],[-79.912903,51.208496],[-79.143005,51.533875],[-78.601929,52.562073],[-79.124207,54.141479],[-79.82959,54.667725],[-78.228699,55.136475],[-77.095581,55.837524],[-76.541382,56.534302],[-76.623108,57.202698],[-77.302185,58.052124],[-78.516907,58.804688],[-77.33667,59.852722],[-77.772705,60.757874],[-78.106812,62.319702],[-77.410583,62.550476],[-75.696228,62.278503],[-74.668213,62.181091],[-73.839905,62.443909],[-72.908508,62.105103],[-71.677002,61.52533],[-71.373718,61.137085],[-69.590393,61.061523],[-69.6203,60.221313],[-69.287903,58.957275],[-68.374512,58.801086],[-67.64978,58.212097],[-66.201782,58.767273],[-65.245178,59.870728],[-64.583496,60.335693],[-63.804687,59.442688],[-62.50238,58.167114],[-61.396484,56.967529],[-61.798584,56.339478],[-60.468506,55.775513],[-59.56958,55.204102],[-57.975098,54.945496],[-57.333191,54.626526],[-56.93689,53.780273],[-56.158081,53.647522],[-55.756287,53.270508],[-55.683289,52.146729],[-56.40918,51.770691],[-57.126892,51.419678],[-58.77478,51.06427],[-60.033081,50.24292],[-61.723572,50.080505],[-63.862488,50.291077],[-65.363281,50.298279],[-66.398987,50.228882],[-67.236328,49.511475],[-68.511108,49.068481],[-69.953613,47.744873],[-71.104492,46.821716],[-70.255188,46.986084],[-68.650024,48.30011],[-66.552429,49.133118],[-65.056213,49.23291],[-64.171021,48.742493],[-65.115479,48.070923],[-64.798523,46.993103],[-64.472107,46.238525],[-63.173279,45.739075],[-61.520691,45.883911],[-60.518127,47.007874],[-60.448608,46.282715],[-59.802795,45.920471],[-61.039795,45.26532],[-63.2547,44.670288],[-64.246582,44.265503],[-65.364075,43.545288],[-66.123413,43.618713],[-66.161682,44.465088],[-64.425476,45.292114],[-66.026001,45.259277],[-67.13739,45.137512],[-66.9646,44.809692],[-68.032471,44.325317],[-69.059998,43.980103],[-70.116089,43.684082],[-70.690002,43.03009],[-70.81488,42.865295],[-70.825012,42.335083],[-70.494995,41.805115],[-70.080017,41.78009],[-70.184998,42.145081],[-69.884888,41.922913],[-69.965027,41.637085],[-70.640015,41.475098],[-71.1203,41.494507],[-71.859985,41.320129],[-72.294983,41.270081],[-72.876404,41.220703],[-73.710022,40.931091],[-72.241211,41.119507],[-71.945007,40.930115],[-73.34491,40.630127],[-73.981995,40.628113],[-73.952271,40.750671],[-74.256714,40.473511],[-73.962402,40.427673],[-74.178406,39.70929],[-74.906006,38.939514],[-74.980408,39.196472],[-75.200012,39.248474],[-75.528076,39.498474],[-75.320007,38.960083],[-75.083496,38.781311],[-75.056702,38.404114],[-75.37738,38.015503],[-75.940186,37.216919],[-76.031189,37.256714],[-75.721985,37.937073],[-76.232788,38.319275],[-76.349976,39.150085],[-76.542725,38.717712],[-76.329285,38.083313],[-76.960022,38.23291],[-76.301575,37.918091],[-76.258728,36.966492],[-75.971802,36.897278],[-75.867981,36.551331],[-75.727478,35.55072],[-76.363098,34.808472],[-77.397583,34.512085],[-78.054871,33.925476],[-78.554321,33.861328],[-79.060608,33.49408],[-79.203491,33.158508],[-80.30127,32.509277],[-80.86499,32.033325],[-81.336304,31.440491],[-81.490417,30.730103],[-81.313721,30.035522],[-80.97998,29.180115],[-80.535583,28.472107],[-80.530029,28.0401],[-80.056519,26.880127],[-80.088013,26.205688],[-80.13147,25.816895],[-80.380981,25.206299],[-80.679993,25.080078],[-81.172119,25.201294],[-81.330017,25.640076],[-81.710022,25.870117],[-82.23999,26.730103],[-82.705078,27.495117],[-82.855286,27.886292],[-82.650024,28.55011],[-82.929993,29.100098],[-83.709595,29.936707],[-84.099976,30.090088],[-85.108826,29.636292],[-85.287781,29.686096],[-85.77301,30.15271],[-86.400024,30.400085],[-87.530273,30.274475],[-88.417786,30.384888],[-89.180481,30.316101],[-89.604919,30.176331],[-89.413696,29.894287],[-89.429993,29.488708],[-89.21759,29.291077],[-89.408203,29.159729],[-89.779297,29.307129],[-90.154602,29.117493],[-90.880188,29.148682],[-91.626709,29.677124],[-92.499084,29.552307],[-93.226379,29.783875],[-93.848389,29.713684],[-94.690002,29.480103],[-95.600281,28.738708],[-96.593994,28.307495],[-97.140015,27.830078],[-97.369995,27.380127],[-97.380005,26.690125],[-97.330017,26.210083],[-97.140198,25.869507],[-97.138611,25.86792],[-97.141785,25.865906],[-97.528076,24.992126],[-97.702881,24.272278],[-97.776001,22.932678],[-97.872375,22.444275],[-97.698975,21.898682],[-97.388977,21.411072],[-97.18927,20.635498],[-96.525513,19.89093],[-96.292114,19.320496],[-95.900879,18.828125],[-94.838989,18.562683],[-94.42572,18.144287],[-93.548584,18.423889],[-92.786072,18.524902],[-92.037292,18.704712],[-91.407898,18.876099],[-90.77179,19.284119],[-90.533508,19.867493],[-90.451477,20.70752],[-90.278625,20.999878],[-89.601318,21.261719],[-88.543884,21.493713],[-87.658386,21.458923],[-87.05188,21.543518],[-86.812012,21.331482],[-86.845886,20.849915],[-87.383301,20.255493],[-87.620972,19.646484],[-87.436707,19.472473],[-87.586487,19.0401],[-87.837219,18.259888],[-88.090576,18.516724],[-88.299988,18.500122],[-88.296326,18.353271],[-88.106812,18.348694],[-88.123413,18.076721],[-88.285278,17.644287],[-88.197876,17.489502],[-88.302612,17.131714],[-88.239502,17.036072],[-88.355408,16.530884],[-88.551819,16.265503],[-88.732422,16.233704],[-88.930603,15.887329],[-88.604614,15.706482],[-88.518311,15.85553],[-88.224976,15.727722],[-88.121094,15.688721],[-87.901794,15.864502],[-87.615601,15.878906],[-87.522888,15.797302],[-87.367676,15.846924],[-86.903198,15.756714],[-86.440918,15.782898],[-86.119202,15.893494],[-86.001892,16.005493],[-85.683289,15.953674],[-85.44397,15.885681],[-85.182373,15.909302],[-84.983704,15.995911],[-84.526978,15.8573],[-84.368225,15.835083],[-84.062988,15.648315],[-83.773987,15.424072],[-83.4104,15.270874],[-83.147217,14.995911],[-83.233215,14.899902],[-83.28418,14.676697],[-83.182129,14.31073],[-83.412476,13.970093],[-83.519775,13.567688],[-83.552185,13.127075],[-83.498474,12.869324],[-83.473328,12.419128],[-83.626099,12.320923],[-83.719604,11.893127],[-83.650879,11.629089],[-83.855408,11.373291],[-83.808899,11.103088],[-83.655579,10.938904],[-83.402283,10.395508],[-83.015686,9.993103],[-82.546204,9.566284],[-82.187073,9.20752],[-82.207581,8.995728],[-81.808594,8.950684],[-81.714111,9.032104],[-81.439209,8.786316],[-80.947327,8.858521],[-80.521912,9.111084],[-79.914612,9.312683],[-79.573303,9.611694],[-79.021179,9.552917],[-79.058411,9.454712],[-78.500916,9.420471],[-78.055908,9.247681],[-77.729492,8.946899],[-77.353271,8.670471],[-76.836609,8.638672],[-76.086304,9.336914],[-75.674622,9.443298],[-75.664673,9.774109],[-75.480408,10.61908],[-74.906921,11.08313],[-74.276672,11.102112],[-74.197205,11.310486],[-73.414673,11.227112],[-72.627808,11.732117],[-72.23822,11.955688],[-71.754089,12.437317],[-71.39978,12.376099],[-71.13739,12.113098],[-71.331604,11.776306],[-71.359985,11.5401],[-71.947021,11.423279],[-71.620789,10.969482],[-71.632996,10.446472],[-72.074097,9.865723],[-71.695618,9.072327],[-71.264587,9.137329],[-71.039978,9.860107],[-71.350098,10.211914],[-71.400574,10.969116],[-70.155212,11.375488],[-70.293823,11.846924],[-69.943176,12.162292],[-69.58429,11.459717],[-68.882996,11.443481],[-68.233276,10.885681],[-68.194092,10.554688],[-67.296204,10.545898],[-66.227783,10.648682],[-65.655212,10.200928],[-64.890381,10.077271],[-64.329407,10.389709],[-64.317993,10.641479],[-63.079285,10.701721],[-61.88092,10.715698],[-62.730103,10.420288],[-62.388489,9.948303],[-61.588684,9.873108],[-60.830505,9.381287],[-60.671204,8.580322],[-60.150085,8.602905],[-59.758301,8.367126],[-59.101685,7.999329],[-58.48291,7.347717],[-58.454895,6.832886],[-58.078125,6.809082],[-57.542175,6.321289],[-57.1474,5.973083],[-55.94928,5.772888],[-55.841797,5.953125],[-55.033203,6.02533],[-53.958008,5.756531],[-53.618408,5.646484],[-52.88208,5.409912],[-51.823303,4.565918],[-51.657776,4.156311],[-51.317078,4.203491],[-51.069702,3.650513],[-50.508789,1.901489],[-49.973999,1.736511],[-49.947083,1.046326],[-50.69928,0.223083],[-50.388184,-0.078369],[-48.620483,-0.235413],[-48.584412,-1.237793],[-47.82489,-0.581604],[-46.566589,-0.940979],[-44.905701,-1.551697],[-44.417603,-2.137695],[-44.581604,-2.691284],[-43.418701,-2.383118],[-41.472595,-2.911987],[-39.978577,-2.872986],[-38.500305,-3.700623],[-37.223206,-4.820923],[-36.452881,-5.109375],[-35.597778,-5.149475],[-35.235413,-5.464905],[-34.895996,-6.73822],[-34.72998,-7.343201],[-35.128174,-8.996399],[-35.636902,-9.649292],[-37.046509,-11.04071],[-37.683594,-12.171204],[-38.423889,-13.038086],[-38.673889,-13.057678],[-38.953186,-13.793396],[-38.882324,-15.666992],[-39.161011,-17.208374],[-39.267273,-17.867676],[-39.583496,-18.262207],[-39.760803,-19.599121],[-40.774719,-20.90448],[-40.944702,-21.937317],[-41.754089,-22.370605],[-41.988281,-22.970093],[-43.074707,-22.967712],[-44.647827,-23.35199],[-45.352112,-23.796814],[-46.472107,-24.088989],[-47.648987,-24.885193],[-48.495483,-25.877014],[-48.640991,-26.623718],[-48.47467,-27.175903],[-48.661499,-28.186096],[-48.888428,-28.674072],[-49.58728,-29.224487],[-50.696899,-30.984375],[-51.576172,-31.77771],[-52.256104,-32.2453],[-52.712097,-33.196594],[-53.373596,-33.768311],[-53.806396,-34.39679],[-54.935791,-34.952576],[-55.674011,-34.752686],[-56.21521,-34.859802],[-57.139709,-34.430481],[-57.81781,-34.462524],[-58.427002,-33.909485],[-58.495422,-34.431519],[-57.225769,-35.288025],[-57.362305,-35.977417],[-56.737488,-36.413086],[-56.788208,-36.901489],[-57.749084,-38.183899],[-59.231812,-38.720215],[-61.237427,-38.928406],[-62.335876,-38.827698],[-62.125793,-39.424072],[-62.330505,-40.172607],[-62.145996,-40.67688],[-62.745789,-41.028687],[-63.770508,-41.166809],[-64.732117,-40.802612],[-65.117981,-41.06427],[-64.978577,-42.057983],[-64.303406,-42.359009],[-63.75592,-42.043701],[-63.458008,-42.56311],[-64.378784,-42.873474],[-65.181824,-43.4953],[-65.328796,-44.501282],[-65.565186,-45.036804],[-66.509888,-45.039612],[-67.293823,-45.55188],[-67.580505,-46.301697],[-66.596985,-47.033875],[-65.640991,-47.236084],[-65.985107,-48.133301],[-67.166199,-48.697327],[-67.816101,-49.86969],[-68.728699,-50.264221],[-69.138489,-50.732483],[-68.815491,-51.771118],[-68.150024,-52.349976],[-68.571472,-52.299377],[-69.461304,-52.29187],[-69.942688,-52.537903],[-70.845093,-52.89917],[-71.006287,-53.833191],[-71.42981,-53.856384],[-72.557922,-53.531372],[-73.702698,-52.835083],[-74.946777,-52.262695],[-75.26001,-51.629272],[-74.976624,-51.043396],[-75.479675,-50.378296],[-75.607971,-48.673706],[-75.182678,-47.711914],[-74.126587,-46.939209],[-75.644409,-46.647583],[-74.692078,-45.763977],[-74.351685,-44.103027],[-73.240295,-44.454895],[-72.717712,-42.383301],[-73.388916,-42.117493],[-73.701294,-43.365784],[-74.331909,-43.224976],[-74.017883,-41.7948],[-73.677124,-39.9422],[-73.21759,-39.258606],[-73.505493,-38.282898],[-73.588013,-37.156311],[-73.166687,-37.123779],[-72.553101,-35.508789],[-71.861694,-33.909119],[-71.438477,-32.418884],[-71.668701,-30.920593],[-71.369995,-30.095703],[-71.489807,-28.861389],[-70.90509,-27.640381],[-70.724976,-25.705872],[-70.403992,-23.628906],[-70.091187,-21.393311],[-70.164429,-19.756409],[-70.372498,-18.3479],[-71.375183,-17.773804],[-71.461975,-17.363403],[-73.444519,-16.359375],[-75.237793,-15.265686],[-76.009216,-14.649292],[-76.423401,-13.823181],[-76.259216,-13.534973],[-77.106201,-12.222717],[-78.092102,-10.377686],[-79.036926,-8.386597],[-79.445923,-7.930786],[-79.760498,-7.194275],[-80.537476,-6.541687],[-81.25,-6.13678],[-80.92627,-5.690491],[-81.410889,-4.736694],[-81.099609,-4.036377],[-80.30249,-3.404785],[-79.770203,-2.657471],[-79.986511,-2.220703],[-80.368713,-2.685181],[-80.967712,-2.246887],[-80.764771,-1.965027],[-80.933594,-1.057373],[-80.583313,-0.906677],[-80.399292,-0.283691],[-80.020813,0.360474],[-80.090576,0.768494],[-79.542786,0.98291],[-78.855286,1.38092],[-78.990906,1.691284],[-78.617798,1.766479],[-78.662109,2.267273],[-78.427612,2.6297],[-77.931519,2.696716],[-77.510376,3.325073],[-77.127686,3.84967],[-77.496277,4.087708],[-77.307617,4.668091],[-77.533203,5.582886],[-77.318787,5.845276],[-77.476685,6.691101],[-77.881592,7.223877],[-78.214905,7.512329],[-78.429077,8.052124],[-78.182007,8.319275],[-78.435486,8.387695],[-78.62207,8.718079],[-79.1203,8.996094],[-79.5578,8.932495],[-79.760498,8.584473],[-80.16449,8.333313],[-80.382629,8.298523],[-80.480713,8.090271],[-80.003601,7.547485],[-80.276611,7.419678],[-80.421082,7.271484],[-80.886414,7.22052],[-81.059509,7.817871],[-81.189697,7.647888],[-81.51947,7.706726],[-81.721313,8.108887],[-82.131409,8.175476],[-82.390869,8.29248],[-82.820007,8.290894],[-82.850891,8.073914],[-82.965698,8.225098],[-83.508423,8.446899],[-83.711487,8.656921],[-83.596313,8.830505],[-83.632629,9.051514],[-83.909912,9.290894],[-84.303406,9.487488],[-84.647583,9.615479],[-84.713379,9.908081],[-84.975586,10.086731],[-84.911377,9.796082],[-85.110901,9.557129],[-85.339478,9.834473],[-85.660706,9.933289],[-85.797424,10.134888],[-85.791687,10.43927],[-85.659302,10.754272],[-85.941711,10.895325],[-85.712524,11.088501],[-86.058411,11.403503],[-86.525879,11.806885],[-86.745911,12.144104],[-87.16748,12.458313],[-87.668518,12.909912],[-87.557495,13.064697],[-87.392395,12.914124],[-87.316589,12.98468],[-87.48938,13.297485],[-87.793091,13.384521],[-87.904114,13.149109],[-88.483276,13.163879],[-88.843201,13.259705],[-89.256714,13.458679],[-89.812378,13.520691],[-90.095581,13.735474],[-90.608582,13.909912],[-91.232422,13.927917],[-91.689697,14.126282],[-92.227722,14.538879],[-93.359375,15.615479],[-93.875183,15.940308],[-94.691589,16.201111],[-95.250183,16.128296],[-96.053406,15.752075],[-96.557373,15.653503],[-97.263611,15.917114],[-98.013,16.1073],[-98.947693,16.566101],[-99.697388,16.706299],[-100.829529,17.171082],[-101.666077,17.649109],[-101.918518,17.916077],[-102.478088,17.975891],[-103.500977,18.292297],[-103.91748,18.748718],[-104.992004,19.316284],[-105.492981,19.946899],[-105.731384,20.434082],[-105.397705,20.531677],[-105.50061,20.816895],[-105.270691,21.076294],[-105.265808,21.422119],[-105.603088,21.871277],[-105.69342,22.269104],[-106.028687,22.773682],[-106.909912,23.767883],[-107.915405,24.548889],[-108.401917,25.172302],[-109.260193,25.580688],[-109.444092,25.82489],[-109.291626,26.442871],[-109.801392,26.676086],[-110.391724,27.162109],[-110.640991,27.859924],[-111.178894,27.941284],[-111.759583,28.468079],[-112.22821,28.954529],[-112.27179,29.266907],[-112.809509,30.021118],[-113.163818,30.786926],[-113.148682,31.171082],[-113.871887,31.567688],[-114.205688,31.524109],[-114.776428,31.799683],[-114.936707,31.393494],[-114.771179,30.913696],[-114.673889,30.16272],[-114.330994,29.750488],[-113.588806,29.061707],[-113.424011,28.826294],[-113.271912,28.754883],[-113.140015,28.411316],[-112.96228,28.425293],[-112.761597,27.780273],[-112.457886,27.525879],[-112.244873,27.171875],[-111.616516,26.662903],[-111.284607,25.732727],[-110.987793,25.294678],[-110.710022,24.826111],[-110.655029,24.298706],[-110.172791,24.265686],[-109.77179,23.811279],[-109.409119,23.364685],[-109.433411,23.18573],[-109.854187,22.818298],[-110.031311,22.82312],[-110.294983,23.431091],[-110.949524,24.001099],[-111.670593,24.484497],[-112.182007,24.738525],[-112.148987,25.470276],[-112.30072,26.012085],[-112.777283,26.322083],[-113.4646,26.768311],[-113.59668,26.639526],[-113.848877,26.900085],[-114.465698,27.14209],[-115.055115,27.722717],[-114.982178,27.798279],[-114.570312,27.741516],[-114.19928,28.115112],[-114.161987,28.566101],[-114.931824,29.27948],[-115.518677,29.556274],[-115.88739,30.180908],[-116.258301,30.836487],[-116.721497,31.635681],[-117.127686,32.535278],[-117.295898,33.046326],[-117.943909,33.621277],[-118.410583,33.740906],[-118.519897,34.027893],[-119.080994,34.078125],[-119.438782,34.348511],[-120.367798,34.447083],[-120.622803,34.608521],[-120.744324,35.156921],[-121.7146,36.161682],[-122.547485,37.55188],[-122.512024,37.783508],[-122.953186,38.113708],[-123.727112,38.951721],[-123.865112,39.76709],[-124.39801,40.313293],[-124.178772,41.14209],[-124.213684,41.999695],[-124.532776,42.766113],[-124.14209,43.708496],[-123.898926,45.523499],[-124.07959,46.864685],[-124.395691,47.720276],[-124.687195,48.184509],[-124.566101,48.3797],[-123.119995,48.0401],[-122.58728,47.09613],[-122.340027,47.360107],[-122.5,48.180115],[-122.840027,49.000122],[-122.974182,49.002686],[-124.910217,49.98468],[-125.624573,50.416687],[-127.435608,50.830688],[-127.992676,51.715881],[-127.850281,52.329712],[-129.1297,52.755493],[-129.305176,53.561707],[-130.514893,54.28772],[-130.536072,54.802673],[-131.085815,55.178894],[-131.967224,55.497925],[-132.25,56.370117],[-133.539185,57.178894],[-134.078003,58.123108],[-135.038208,58.187683],[-136.627991,58.21228],[-137.799988,58.500122],[-139.867798,59.537903],[-140.825195,59.727478],[-142.574402,60.084473],[-143.958801,59.999329],[-145.925476,60.458679],[-147.11438,60.884705],[-148.224304,60.673096],[-148.018005,59.978271],[-148.570801,59.914307],[-149.727783,59.705688],[-150.608215,59.368286],[-151.716309,59.155884],[-151.859375,59.745117],[-151.409729,60.725891],[-150.346924,61.033691],[-150.621094,61.284485],[-151.895813,60.727295],[-152.578308,60.061707],[-154.019104,59.350281],[-153.287476,58.864685],[-154.232483,58.146484],[-155.307495,57.727905],[-156.308289,57.422913],[-156.556091,56.980103],[-158.117187,56.463684],[-158.433289,55.99408],[-159.603271,55.566711],[-160.289673,55.643677],[-161.223022,55.364685],[-162.237793,55.024292],[-163.069397,54.68988],[-164.785583,54.404297],[-164.9422,54.572327],[-163.848328,55.03949],[-162.869995,55.348083],[-161.804199,55.895081],[-160.563599,56.008118],[-160.070496,56.418091],[-158.684387,57.016724],[-158.461121,57.216919],[-157.722778,57.570129],[-157.550293,58.328308],[-157.041687,58.918884],[-158.194702,58.615906],[-158.517212,58.787903],[-159.058594,58.424316],[-159.711609,58.931519],[-159.981201,58.572693],[-160.355286,59.071106],[-161.35498,58.670898],[-161.968811,58.671692],[-162.054993,59.266907],[-161.874084,59.633728],[-162.518005,59.989685],[-163.818298,59.798096],[-164.66217,60.267517],[-165.346375,60.507507],[-165.350769,61.073914],[-166.121399,61.500122],[-165.734375,62.075073],[-164.919189,62.633118],[-164.5625,63.146484],[-163.753296,63.219482],[-163.0672,63.059509],[-162.260498,63.54187],[-161.534424,63.455872],[-160.772522,63.766113],[-160.958313,64.2229],[-161.518005,64.402893],[-160.77771,64.788696],[-161.391907,64.777283],[-162.453003,64.559509],[-162.757813,64.338684],[-163.546387,64.559082],[-164.960815,64.447083],[-166.425293,64.686707],[-166.844971,65.088928],[-168.110474,65.670105],[-166.7052,66.088318],[-164.47467,66.576721],[-163.652527,66.576721],[-163.788513,66.077271],[-161.677795,66.116089],[-162.489685,66.735474],[-163.719727,67.116516],[-164.430908,67.616272],[-165.390198,68.042908],[-166.764404,68.358887],[-166.204712,68.883118],[-164.430786,68.915527],[-163.168579,69.371094],[-162.930481,69.858093],[-161.908875,70.333313],[-160.934814,70.447693],[-159.039185,70.891724],[-158.11969,70.824707],[-156.580811,71.35791],[-155.06781,71.147888],[-154.344177,70.696472],[-153.900024,70.890076],[-152.210022,70.830078],[-152.27002,70.600098],[-150.73999,70.430115],[-149.719971,70.53009],[-147.613281,70.214111],[-145.690002,70.120117],[-144.919983,69.990112],[-143.589417,70.152527],[-142.07251,69.851929],[-140.985901,69.712097],[-139.120483,69.47113],[-137.546387,68.990112],[-136.503601,68.898071],[-135.625671,69.315125],[-134.414612,69.627502],[-132.929199,69.50531],[-131.431274,69.944519],[-129.794678,70.193726],[-129.107727,69.779297],[-128.361511,70.012878],[-128.138184,70.483887],[-127.447083,70.377319],[-125.756287,69.480713],[-124.424805,70.158508],[-124.289612,69.399719],[-123.061096,69.563721],[-122.683411,69.85553],[-121.47229,69.797913],[-119.94281,69.37793],[-117.6026,69.011292],[-116.226379,68.841492],[-115.246887,68.905884],[-113.897888,68.398926],[-115.30481,67.90271],[-113.497192,67.688293],[-110.797913,67.806091],[-109.946106,67.981079],[-108.880188,67.381531],[-107.792419,67.887512],[-108.812988,68.311707],[-108.167175,68.653931],[-106.950012,68.700073],[-106.150024,68.80011],[-105.342773,68.561279],[-104.337891,68.018127],[-103.221069,68.0979],[-101.454285,67.646912],[-99.901978,67.805725],[-98.443176,67.781677],[-98.558594,68.403931],[-97.669495,68.578674],[-96.119873,68.239502],[-96.125793,67.293518],[-95.48938,68.090698],[-94.684998,68.063904],[-94.232788,69.069092],[-95.304077,69.68573],[-96.471313,70.089905],[-96.391113,71.194885],[-95.208801,71.920471],[-93.889893,71.760071],[-92.878113,71.318726],[-91.519592,70.191284],[-92.406921,69.700073],[-90.547119,69.497681]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-119.40199,68.53554,-100.98078,73.31459],"geometry":{"type":"Polygon","coordinates":[[[-114.16717,73.12145],[-114.66634,72.65277],[-112.44102,72.9554],[-111.05039,72.4504],[-109.92035,72.96113],[-109.00654,72.63335],[-108.18835,71.65089],[-107.68599,72.06548],[-108.39639,73.08953],[-107.51645,73.23598],[-106.52259,73.07601],[-105.40246,72.67259],[-104.77484,71.6984],[-104.46476,70.99297],[-102.78537,70.49776],[-100.98078,70.02432],[-101.08929,69.58447],[-102.73116,69.50402],[-102.09329,69.11962],[-102.43024,68.75282],[-104.24,68.91],[-105.96,69.18],[-107.12254,69.11922],[-109,68.78],[-111.9668,68.60446],[-113.3132,68.53554],[-113.85496,69.00744],[-115.22,69.28],[-116.10794,69.16821],[-117.34,69.96],[-116.67473,70.06655],[-115.13112,70.2373],[-113.72141,70.19237],[-112.4161,70.36638],[-114.35,70.6],[-116.48684,70.52045],[-117.9048,70.54056],[-118.43238,70.9092],[-116.11311,71.30918],[-117.65568,71.2952],[-119.40199,71.55859],[-118.56267,72.30785],[-117.86642,72.70594],[-115.18909,73.31459],[-114.16717,73.12145]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1.5},"bbox":[-106.94,72.76,-104.5,73.64],"geometry":{"type":"Polygon","coordinates":[[[-104.5,73.42],[-105.38,72.76],[-106.94,73.46],[-106.6,73.6],[-105.26,73.64],[-104.5,73.42]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[-80.876099,72.742203,-76.251404,73.75972],"geometry":{"type":"Polygon","coordinates":[[[-76.34,73.102685],[-76.251404,72.826385],[-77.314438,72.855545],[-78.39167,72.876656],[-79.486252,72.742203],[-79.775833,72.802902],[-80.876099,73.333183],[-80.833885,73.693184],[-80.353058,73.75972],[-78.064438,73.651932],[-76.34,73.102685]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-90.20516,61.930897,-61.851981,73.803816],"geometry":{"type":"Polygon","coordinates":[[[-86.562179,73.157447],[-85.774371,72.534126],[-84.850112,73.340278],[-82.31559,73.750951],[-80.600088,72.716544],[-80.748942,72.061907],[-78.770639,72.352173],[-77.824624,72.749617],[-75.605845,72.243678],[-74.228616,71.767144],[-74.099141,71.33084],[-72.242226,71.556925],[-71.200015,70.920013],[-68.786054,70.525024],[-67.91497,70.121948],[-66.969033,69.186087],[-68.805123,68.720198],[-66.449866,68.067163],[-64.862314,67.847539],[-63.424934,66.928473],[-61.851981,66.862121],[-62.163177,66.160251],[-63.918444,64.998669],[-65.14886,65.426033],[-66.721219,66.388041],[-68.015016,66.262726],[-68.141287,65.689789],[-67.089646,65.108455],[-65.73208,64.648406],[-65.320168,64.382737],[-64.669406,63.392927],[-65.013804,62.674185],[-66.275045,62.945099],[-68.783186,63.74567],[-67.369681,62.883966],[-66.328297,62.280075],[-66.165568,61.930897],[-68.877367,62.330149],[-71.023437,62.910708],[-72.235379,63.397836],[-71.886278,63.679989],[-73.378306,64.193963],[-74.834419,64.679076],[-74.818503,64.389093],[-77.70998,64.229542],[-78.555949,64.572906],[-77.897281,65.309192],[-76.018274,65.326969],[-73.959795,65.454765],[-74.293883,65.811771],[-73.944912,66.310578],[-72.651167,67.284576],[-72.92606,67.726926],[-73.311618,68.069437],[-74.843307,68.554627],[-76.869101,68.894736],[-76.228649,69.147769],[-77.28737,69.76954],[-78.168634,69.826488],[-78.957242,70.16688],[-79.492455,69.871808],[-81.305471,69.743185],[-84.944706,69.966634],[-87.060003,70.260001],[-88.681713,70.410741],[-89.51342,70.762038],[-88.467721,71.218186],[-89.888151,71.222552],[-90.20516,72.235074],[-89.436577,73.129464],[-88.408242,73.537889],[-85.826151,73.803816],[-86.562179,73.157447]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-102.5,71.27285,-96.54,73.84389],"geometry":{"type":"Polygon","coordinates":[[[-100.35642,73.84389],[-99.16387,73.63339],[-97.38,73.76],[-97.12,73.47],[-98.05359,72.99052],[-96.54,72.56],[-96.72,71.66],[-98.35966,71.27285],[-99.32286,71.35639],[-100.01482,71.73827],[-102.5,72.51],[-102.48,72.83],[-100.43836,72.70588],[-101.54,73.36],[-100.35642,73.84389]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[139.86312,73.20544,143.60385,73.85758],"geometry":{"type":"Polygon","coordinates":[[[143.60385,73.21244],[142.08763,73.20544],[140.038155,73.31692],[139.86312,73.36983],[140.81171,73.76506],[142.06207,73.85758],[143.48283,73.47525],[143.60385,73.21244]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0.5},"bbox":[-96.033745,72.024596,-90.509793,74.134907],"geometry":{"type":"Polygon","coordinates":[[[-93.196296,72.771992],[-94.269047,72.024596],[-95.409856,72.061881],[-96.033745,72.940277],[-96.018268,73.43743],[-95.495793,73.862417],[-94.503658,74.134907],[-92.420012,74.100025],[-90.509793,73.856732],[-92.003965,72.966244],[-93.196296,72.771992]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-125.92896,70.90164,-115.51081,74.44893],"geometry":{"type":"Polygon","coordinates":[[[-120.46,71.4],[-123.09219,70.90164],[-123.62,71.34],[-125.92896,71.86868],[-125.59271,72.19452],[-124.80729,73.02256],[-123.94,73.68],[-124.91775,74.29275],[-121.53788,74.44893],[-120.10978,74.24135],[-117.55564,74.18577],[-116.58442,73.89607],[-115.51081,73.47519],[-116.76794,73.22292],[-119.22,72.52],[-120.46,71.82],[-120.46,71.4]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":1},"bbox":[146.11919,74.68892,150.73167,75.49682],"geometry":{"type":"Polygon","coordinates":[[[150.73167,75.08406],[149.575925,74.68892],[147.977465,74.778355],[146.11919,75.17298],[146.358485,75.49682],[148.22223,75.345845],[150.73167,75.08406]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[-96.820932,74.592347,-93.612756,75.647218],"geometry":{"type":"Polygon","coordinates":[[[-93.612756,74.979997],[-94.156909,74.592347],[-95.608681,74.666864],[-96.820932,74.927623],[-96.288587,75.377828],[-94.85082,75.647218],[-93.977747,75.29649],[-93.612756,74.979997]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[136.97439,74.61148,145.086285,76.13676],"geometry":{"type":"Polygon","coordinates":[[[145.086285,75.562625],[144.3,74.82],[140.61381,74.84768],[138.95544,74.61148],[136.97439,75.26167],[137.51176,75.94917],[138.831075,76.13676],[141.471615,76.09289],[145.086285,75.562625]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-102.56552,74.89744,-97.704415,76.72],"geometry":{"type":"Polygon","coordinates":[[[-98.5,76.72],[-97.735585,76.25656],[-97.704415,75.74344],[-98.16,75],[-99.80874,74.89744],[-100.88366,75.05736],[-100.86292,75.64075],[-102.50209,75.5638],[-102.56552,76.3366],[-101.48973,76.30537],[-99.98349,76.64634],[-98.57699,76.58859],[-98.5,76.72]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-117.7104,74.39427,-105.70498,76.79417],"geometry":{"type":"Polygon","coordinates":[[[-108.21141,76.20168],[-107.81943,75.84552],[-106.92893,76.01282],[-105.881,75.9694],[-105.70498,75.47951],[-106.31347,75.00527],[-109.7,74.85],[-112.22307,74.41696],[-113.74381,74.39427],[-113.87135,74.72029],[-111.79421,75.1625],[-116.31221,75.04343],[-117.7104,75.2222],[-116.34602,76.19903],[-115.40487,76.47887],[-112.59056,76.14134],[-110.81422,75.54919],[-109.0671,75.47321],[-110.49726,76.42982],[-109.5811,76.79417],[-108.54859,76.67832],[-108.21141,76.20168]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[51.455754,70.632743,68.852211,76.939697],"geometry":{"type":"Polygon","coordinates":[[[57.535693,70.720464],[56.944979,70.632743],[53.677375,70.762658],[53.412017,71.206662],[51.601895,71.474759],[51.455754,72.014881],[52.478275,72.229442],[52.444169,72.774731],[54.427614,73.627548],[53.50829,73.749814],[55.902459,74.627486],[55.631933,75.081412],[57.868644,75.60939],[61.170044,76.251883],[64.498368,76.439055],[66.210977,76.809782],[68.15706,76.939697],[68.852211,76.544811],[68.180573,76.233642],[64.637326,75.737755],[61.583508,75.260885],[58.477082,74.309056],[56.986786,73.333044],[55.419336,72.371268],[55.622838,71.540595],[57.535693,70.720464]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-97.121379,74.392307,-79.833933,77.161389],"geometry":{"type":"Polygon","coordinates":[[[-94.684086,77.097878],[-93.573921,76.776296],[-91.605023,76.778518],[-90.741846,76.449597],[-90.969661,76.074013],[-89.822238,75.847774],[-89.187083,75.610166],[-87.838276,75.566189],[-86.379192,75.482421],[-84.789625,75.699204],[-82.753445,75.784315],[-81.128531,75.713983],[-80.057511,75.336849],[-79.833933,74.923127],[-80.457771,74.657304],[-81.948843,74.442459],[-83.228894,74.564028],[-86.097452,74.410032],[-88.15035,74.392307],[-89.764722,74.515555],[-92.422441,74.837758],[-92.768285,75.38682],[-92.889906,75.882655],[-93.893824,76.319244],[-95.962457,76.441381],[-97.121379,76.751078],[-96.745123,77.161389],[-94.684086,77.097878]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":0.5},"bbox":[-122.854925,75.900019,-116.198587,77.645287],"geometry":{"type":"Polygon","coordinates":[[[-116.198587,77.645287],[-116.335813,76.876962],[-117.106051,76.530032],[-118.040412,76.481172],[-119.899318,76.053213],[-121.499995,75.900019],[-122.854924,76.116543],[-122.854925,76.116543],[-121.157535,76.864508],[-119.103939,77.51222],[-117.570131,77.498319],[-116.198587,77.645287]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-17.625,-34.819092,180,77.697876],"geometry":{"type":"Polygon","coordinates":[[[106.970276,76.974304],[107.240112,76.480103],[108.153931,76.723328],[111.077271,76.710083],[113.331482,76.22229],[114.134277,75.847717],[113.885498,75.327881],[112.779297,75.031921],[110.151306,74.476685],[109.400085,74.180115],[110.640076,74.0401],[112.119324,73.78772],[113.01947,73.976929],[113.529724,73.335083],[113.968872,73.59491],[115.567871,73.75293],[118.776306,73.587708],[119.020081,73.120117],[123.200684,72.971313],[123.257874,73.735107],[125.380127,73.56012],[126.976501,73.565491],[128.591309,73.038696],[129.051697,72.398682],[128.460083,71.980103],[129.716125,71.193115],[131.288696,70.787109],[132.253479,71.836304],[133.857727,71.386475],[135.562073,71.655273],[137.497681,71.347717],[138.234131,71.628113],[139.869873,71.487915],[139.147888,72.416321],[140.468079,72.849487],[149.500122,72.200073],[150.351318,71.606506],[152.968872,70.842285],[157.006897,71.031494],[158.997925,70.866699],[159.830322,70.453308],[159.708679,69.722107],[160.940674,69.437317],[162.279114,69.64209],[164.05249,69.668274],[165.940491,69.472107],[167.835693,69.582703],[169.577698,68.693909],[170.816895,69.013672],[170.008301,69.652893],[170.453491,70.097107],[173.643921,69.817505],[175.724121,69.877319],[178.600098,69.400085],[180,68.963722],[180,64.979584],[179.99292,64.974304],[178.707275,64.534912],[177.411316,64.608276],[178.31311,64.075928],[178.908325,63.252075],[179.370483,62.982727],[179.486511,62.569092],[179.228271,62.304077],[177.364319,62.521912],[174.569275,61.769287],[173.680115,61.65271],[172.150085,60.950073],[170.698486,60.336304],[170.330872,59.881897],[168.900513,60.573486],[166.295105,59.788696],[165.840088,60.160095],[164.876892,59.731689],[163.539307,59.868713],[163.217102,59.211121],[162.017273,58.243286],[162.053101,57.839111],[163.191895,57.615112],[163.057922,56.159302],[162.1297,56.122314],[161.701477,55.285706],[162.117493,54.855286],[160.368896,54.344482],[160.021729,53.202698],[158.530884,52.958679],[158.231323,51.942688],[156.789917,51.011108],[156.420105,51.700073],[155.991882,53.158875],[155.433716,55.381104],[155.91449,56.767883],[156.758301,57.364685],[156.810486,57.832092],[158.364319,58.055725],[160.150696,59.31488],[161.87207,60.343079],[163.669678,61.14093],[164.473694,62.55072],[163.258484,62.466309],[162.657898,61.642517],[160.121521,60.544312],[159.302307,61.774109],[156.720703,61.434509],[154.218079,59.758301],[155.043884,59.145081],[152.81189,58.883911],[151.265686,58.780884],[151.338074,59.504089],[149.783691,59.655701],[148.544922,59.16449],[145.487305,59.336487],[142.197876,59.0401],[138.958496,57.088074],[135.126282,54.729675],[136.701721,54.603699],[137.193481,53.977295],[138.164673,53.755127],[138.804688,54.2547],[139.901489,54.189697],[141.345276,53.089722],[141.379272,52.238892],[140.597473,51.239685],[140.513123,50.045471],[140.062073,48.446716],[138.554688,46.999695],[138.219727,46.307922],[136.862305,45.143494],[135.51532,43.989075],[134.869507,43.398315],[133.536926,42.811523],[132.906311,42.798523],[132.278076,43.284485],[130.935913,42.552673],[130.78009,42.220093],[130.400085,42.28009],[129.965881,41.941284],[129.66748,41.601074],[129.705322,40.882874],[129.18811,40.661926],[129.010498,40.485474],[128.633484,40.18988],[127.967529,40.025513],[127.533508,39.756897],[127.502075,39.323914],[127.385498,39.213501],[127.783325,39.050903],[128.34967,38.612305],[129.212891,37.432495],[129.46051,36.784302],[129.468323,35.63208],[129.091492,35.08252],[128.185913,34.890503],[127.386475,34.475708],[126.485718,34.390076],[126.373901,34.934692],[126.559326,35.684692],[126.117493,36.725525],[126.860291,36.893921],[126.174683,37.749695],[125.689087,37.940125],[125.568481,37.752075],[125.27533,37.669128],[125.240112,37.8573],[124.981079,37.948914],[124.71228,38.108276],[124.986084,38.548523],[125.221924,38.665894],[125.132874,38.848694],[125.386719,39.387878],[125.321106,39.551514],[124.737488,39.660278],[124.265686,39.928528],[122.867676,39.637878],[122.131531,39.170471],[121.054688,38.897522],[121.586121,39.360901],[121.376892,39.750305],[122.168701,40.422485],[121.640503,40.946472],[120.768677,40.593506],[119.639709,39.898071],[119.023499,39.252319],[118.042725,39.204285],[117.532715,38.737671],[118.059692,38.061523],[118.878296,37.897278],[118.911682,37.448486],[119.702881,37.156494],[120.823486,37.870483],[121.711304,37.481079],[122.35791,37.454529],[122.520081,36.930725],[121.104309,36.651306],[120.637085,36.111511],[119.664673,35.609924],[119.151306,34.909912],[120.227478,34.360474],[120.620483,33.376709],[121.229126,32.460327],[121.908081,31.692322],[121.891907,30.94928],[121.264282,30.676331],[121.503479,30.142883],[122.092102,29.83252],[121.938477,29.018127],[121.684509,28.225525],[121.125671,28.135681],[120.395508,27.053284],[119.58551,25.740906],[118.656921,24.547485],[117.281677,23.624512],[115.890686,22.782898],[114.763916,22.668091],[114.152527,22.223877],[113.806885,22.548279],[113.241089,22.051514],[111.843689,21.550476],[110.785522,21.397278],[110.444092,20.341125],[109.889893,20.282471],[109.627686,21.008301],[109.864502,21.395081],[108.522888,21.715271],[108.050293,21.55249],[106.715088,20.696899],[105.881714,19.752075],[105.662109,19.058289],[106.42688,18.004089],[107.361877,16.69751],[108.26947,16.079712],[108.877075,15.276672],[109.335327,13.426086],[109.200073,11.66687],[108.366089,11.008301],[107.220886,10.364502],[106.40509,9.530884],[105.158325,8.59967],[104.795288,9.241089],[105.076294,9.918518],[104.334473,10.486694],[103.497314,10.63269],[103.090698,11.153687],[102.585083,12.186707],[101.687073,12.645874],[100.831909,12.627075],[100.978516,13.41272],[100.0979,13.406921],[100.018677,12.307129],[99.478882,10.846497],[99.153687,9.963074],[99.222473,9.239319],[99.873901,9.207886],[100.279724,8.295288],[100.45929,7.429687],[101.017273,6.856873],[101.623108,6.740723],[102.141296,6.22168],[102.371277,6.128296],[102.961731,5.524475],[103.381287,4.855103],[103.438721,4.181702],[103.332092,3.726685],[103.429504,3.382874],[103.502502,2.791077],[103.854675,2.515503],[104.247925,1.631287],[104.228882,1.293091],[103.519714,1.226318],[102.57373,1.967102],[101.390686,2.760925],[101.273682,3.270325],[100.695496,3.939087],[100.557495,4.767273],[100.196716,5.3125],[100.306274,6.04071],[100.085876,6.464478],[99.690674,6.848328],[99.519714,7.343506],[98.988281,7.908081],[98.503906,8.382324],[98.339722,7.794495],[98.150085,8.350098],[98.259277,8.973877],[98.553528,9.933105],[98.457275,10.675293],[98.764526,11.441284],[98.428284,12.033081],[98.509705,13.122498],[98.103699,13.640503],[97.77771,14.83728],[97.597107,16.100708],[97.164673,16.928711],[96.50592,16.427307],[95.369324,15.714478],[94.808472,15.803528],[94.188904,16.038086],[94.533508,17.277283],[94.32489,18.213501],[93.541077,19.366516],[93.66333,19.727112],[93.078308,19.855286],[92.36853,20.670898],[92.082886,21.192322],[92.02533,21.701721],[91.8349,22.182922],[91.417114,22.765076],[90.496094,22.805115],[90.587097,22.392883],[90.272888,21.836487],[89.847473,22.039124],[89.702087,21.857117],[89.418884,21.966309],[89.032104,22.055725],[88.888916,21.690674],[88.208496,21.703308],[86.975708,21.495483],[87.033081,20.743286],[86.499329,20.151672],[85.060303,19.478699],[83.941101,18.302124],[83.18927,17.671326],[82.192871,17.016724],[82.191284,16.556702],[81.692688,16.310303],[80.792114,15.952087],[80.32489,15.899292],[80.025085,15.136475],[80.233276,13.835876],[80.286316,13.006287],[79.862488,12.056274],[79.858093,10.3573],[79.340515,10.308899],[78.885498,9.546082],[79.189697,9.216675],[78.278076,8.933105],[77.941284,8.25293],[77.539917,7.965515],[76.593079,8.899292],[76.130127,10.299683],[75.746521,11.308289],[75.396118,11.781311],[74.864929,12.741882],[74.616699,13.992676],[74.443909,14.61731],[73.534302,15.990723],[73.119873,17.928711],[72.820923,19.208313],[72.824524,20.419495],[72.630676,21.356079],[71.175293,20.757507],[70.47052,20.877319],[69.164124,22.089294],[69.644897,22.450684],[69.34967,22.843323],[68.176697,23.692078],[67.443726,23.944885],[67.145508,24.663696],[66.372925,25.425293],[64.530518,25.237122],[62.905701,25.218506],[61.497498,25.078308],[59.616089,25.380127],[58.525879,25.610107],[57.397278,25.739929],[56.970886,26.966125],[56.492126,27.143311],[55.723694,26.964722],[54.715088,26.480713],[53.493103,26.8125],[52.483704,27.580872],[51.520874,27.865723],[50.853088,28.814514],[50.115112,30.147888],[49.576904,29.985718],[48.941284,30.317078],[48.568115,29.92688],[47.974487,29.975891],[48.183289,29.534485],[48.093872,29.306274],[48.416077,28.552124],[48.807678,27.689697],[49.299683,27.461304],[49.470886,27.110107],[50.152527,26.689697],[50.213074,26.2771],[50.113281,25.944092],[50.239929,25.608093],[50.527527,25.327881],[50.660706,24.999878],[50.81012,24.754883],[50.743896,25.482483],[51.013489,26.00708],[51.286499,26.114685],[51.589111,25.801086],[51.606689,25.215698],[51.389709,24.627502],[51.579529,24.245483],[51.757507,24.294128],[51.794495,24.019897],[52.577087,24.17749],[53.404114,24.151306],[54.008118,24.121887],[54.693115,24.797913],[55.439087,25.439087],[56.070923,26.055481],[56.362122,26.395874],[56.485718,26.309082],[56.391479,25.896118],[56.261108,25.714722],[56.396912,24.924683],[56.845276,24.241699],[57.403503,23.878723],[58.137085,23.747925],[58.729309,23.565674],[59.180481,22.992493],[59.450073,22.660278],[59.808105,22.533691],[59.806274,22.310486],[59.442322,21.714478],[59.282471,21.433899],[58.861084,21.114075],[58.488098,20.429077],[58.034302,20.481506],[57.826477,20.243103],[57.665894,19.736084],[57.788696,19.067688],[57.694519,18.944702],[57.234314,18.94812],[56.60968,18.57428],[56.512329,18.087097],[56.283508,17.876099],[55.661499,17.884277],[55.270081,17.632324],[55.274902,17.228271],[54.791077,16.950684],[54.239319,17.045105],[53.570496,16.707703],[53.108704,16.651123],[52.385315,16.382507],[52.191711,15.938477],[52.168274,15.597473],[51.172485,15.175293],[49.574707,14.708679],[48.679321,14.003296],[48.239075,13.94812],[47.938904,14.007324],[47.354492,13.592285],[46.717102,13.399719],[45.877686,13.3479],[45.625122,13.291077],[45.406494,13.026917],[45.144287,12.953918],[44.989685,12.699707],[44.49469,12.72168],[44.17511,12.585876],[43.483093,12.636902],[43.2229,13.220886],[43.251526,13.7677],[43.088074,14.062683],[42.892273,14.802307],[42.604919,15.213318],[42.805115,15.262085],[42.702515,15.718872],[42.82373,15.911682],[42.77948,16.3479],[42.649719,16.774719],[42.348083,17.075928],[42.270874,17.47467],[41.754517,17.83313],[41.221497,18.671692],[40.93927,19.486511],[40.247681,20.174683],[39.801697,20.338928],[39.139526,21.29187],[39.023682,21.986877],[39.066284,22.579712],[38.49292,23.688477],[38.023926,24.078674],[37.483704,24.285522],[37.154907,24.858521],[37.209473,25.084473],[36.931702,25.603088],[36.639709,25.826294],[36.249084,26.570129],[35.64032,27.376526],[35.13031,28.063477],[34.632324,28.058472],[34.787903,28.607483],[34.832275,28.95752],[34.956116,29.356689],[34.922729,29.501282],[34.641724,29.099487],[34.426697,28.344116],[34.15448,27.823303],[33.921509,27.648682],[33.588074,27.971497],[33.136902,28.417725],[32.423279,29.851074],[32.320496,29.760498],[32.734924,28.705322],[33.348877,27.69989],[34.104675,26.142273],[34.473877,25.598694],[34.795105,25.033875],[35.692505,23.926697],[35.493713,23.752502],[35.526123,23.102478],[36.690674,22.204895],[36.866272,22.000122],[37.188721,21.018921],[36.969482,20.837524],[37.114685,19.808105],[37.481873,18.614075],[37.862671,18.36792],[38.410095,17.998291],[38.990723,16.840698],[39.266113,15.922729],[39.81427,15.43573],[41.179321,14.491089],[41.734924,13.921082],[42.276917,13.344116],[42.589722,13.000488],[43.081299,12.699707],[43.317871,12.390076],[43.286499,11.974915],[42.715881,11.735718],[43.145325,11.462097],[43.470703,11.27771],[43.666687,10.864319],[44.11792,10.445679],[44.614319,10.442322],[45.556885,10.69812],[46.645508,10.816528],[47.525696,11.127319],[48.021729,11.193115],[48.378906,11.375488],[48.948303,11.410706],[49.267883,11.430481],[49.728699,11.578918],[50.258911,11.679688],[50.732117,12.021912],[51.111328,12.024719],[51.133911,11.748291],[51.041504,11.166504],[51.045288,10.64093],[50.83429,10.279724],[50.55249,9.19873],[50.070923,8.081726],[49.452698,6.804688],[48.594482,5.339111],[47.740906,4.219482],[46.56488,2.855286],[45.564087,2.045898],[44.068298,1.052917],[43.136108,0.292297],[42.041687,-0.919189],[41.811096,-1.446411],[41.585083,-1.683228],[40.884888,-2.08252],[40.637878,-2.499817],[40.263123,-2.57312],[40.121277,-3.27771],[39.80011,-3.681091],[39.604919,-4.346497],[39.202271,-4.676697],[38.740479,-5.908875],[38.799683,-6.475586],[39.440125,-6.840027],[39.470093,-7.099976],[39.194702,-7.703918],[39.252075,-8.007812],[39.186523,-8.485474],[39.535889,-9.112305],[39.949707,-10.098389],[40.316711,-10.317078],[40.478516,-10.765381],[40.437317,-11.761719],[40.560913,-12.639099],[40.59967,-14.201904],[40.775513,-14.691711],[40.477295,-15.406311],[40.089294,-16.100708],[39.452698,-16.720886],[38.53833,-17.101013],[37.411072,-17.586304],[36.281311,-18.659607],[35.896484,-18.842285],[35.198486,-19.552795],[34.786499,-19.783997],[34.701904,-20.497009],[35.176086,-21.254272],[35.373474,-21.84082],[35.385925,-22.140015],[35.562683,-22.090027],[35.533875,-23.070801],[35.371887,-23.535278],[35.607483,-23.706482],[35.458679,-24.12262],[35.04071,-24.478271],[34.215881,-24.816284],[33.013306,-25.357483],[32.574707,-25.727295],[32.660278,-26.148499],[32.916077,-26.215881],[32.830078,-26.742188],[32.580322,-27.470093],[32.46228,-28.301025],[32.203491,-28.75238],[31.521118,-29.257385],[31.325684,-29.401978],[30.901672,-29.909912],[30.622925,-30.423706],[30.055725,-31.140198],[28.925476,-32.171997],[28.219727,-32.771912],[27.464722,-33.22699],[26.419495,-33.614929],[25.909729,-33.666992],[25.780701,-33.94458],[25.172913,-33.796875],[24.677917,-33.987183],[23.594116,-33.794495],[22.988281,-33.916382],[22.57428,-33.864075],[21.542908,-34.258789],[20.689087,-34.417175],[20.071289,-34.795105],[19.616516,-34.819092],[19.193298,-34.462585],[18.855286,-34.444275],[18.424683,-33.997803],[18.377502,-34.136475],[18.244507,-33.867676],[18.250122,-33.281372],[17.925293,-32.611206],[18.247925,-32.429077],[18.22168,-31.661621],[17.566895,-30.725708],[17.064514,-29.878601],[17.062927,-29.875977],[16.345093,-28.576721],[15.601929,-27.821228],[15.21051,-27.090881],[14.989685,-26.11731],[14.743286,-25.392883],[14.408081,-23.853027],[14.385681,-22.656677],[14.25769,-22.111206],[13.868713,-21.698975],[13.352478,-20.872803],[12.826904,-19.673096],[12.608704,-19.045288],[11.794922,-18.069092],[11.734314,-17.30188],[11.640076,-16.673096],[11.778687,-15.793823],[12.123718,-14.878296],[12.17572,-14.449097],[12.500122,-13.547729],[12.738525,-13.137878],[13.312927,-12.483582],[13.633728,-12.038574],[13.738708,-11.297791],[13.686523,-10.731079],[13.387329,-10.373596],[13.121094,-9.766907],[12.875488,-9.16687],[12.929077,-8.959106],[13.236511,-8.562622],[12.933105,-7.596497],[12.728271,-6.927124],[12.227478,-6.294373],[12.32251,-6.100098],[12.182312,-5.789917],[11.9151,-5.037903],[11.093689,-3.978821],[10.066284,-2.969482],[9.405273,-2.144287],[8.798096,-1.111328],[8.830078,-0.778992],[9.048523,-0.45929],[9.291321,0.268677],[9.49292,1.010071],[9.305725,1.160889],[9.649292,2.283875],[9.795288,3.073486],[9.40448,3.734497],[8.94812,3.904114],[8.744873,4.352295],[8.488892,4.495728],[8.500305,4.772095],[7.462097,4.412109],[7.082703,4.464722],[6.69812,4.240723],[5.898315,4.262512],[5.362915,4.888123],[5.033691,5.611877],[4.325684,6.270691],[3.57428,6.258301],[2.691711,6.258911],[1.865295,6.142273],[1.06012,5.928894],[-0.507629,5.343506],[-1.063599,5.000488],[-1.964722,4.71051],[-2.856079,4.994507],[-3.311096,4.984314],[-4.008789,5.179871],[-4.649902,5.168274],[-5.834412,4.993713],[-6.528687,4.705078],[-7.518921,4.338318],[-7.712097,4.364685],[-7.974121,4.355896],[-9.004822,4.83252],[-9.913391,5.593689],[-10.765381,6.140686],[-11.438782,6.785889],[-11.708191,6.860107],[-12.428101,7.262878],[-12.948975,7.798706],[-13.124023,8.163879],[-13.246521,8.903076],[-13.685181,9.494873],[-14.073975,9.886292],[-14.330078,10.015686],[-14.579712,10.214478],[-14.693176,10.656311],[-14.839478,10.876709],[-15.13031,11.040527],[-15.664185,11.458496],[-16.085205,11.524719],[-16.314697,11.806519],[-16.308899,11.958679],[-16.61377,12.170898],[-16.677429,12.384888],[-16.841492,13.151489],[-16.713684,13.595093],[-17.126099,14.373474],[-17.625,14.729675],[-17.185181,14.919495],[-16.700684,15.621521],[-16.463013,16.135071],[-16.549683,16.673889],[-16.270508,17.167114],[-16.146301,18.108521],[-16.256897,19.09668],[-16.377625,19.593872],[-16.277771,20.092529],[-16.536316,20.567871],[-17.063416,20.999878],[-17.020386,21.422302],[-16.973206,21.885681],[-16.589111,22.158325],[-16.261902,22.679321],[-16.326416,23.017883],[-15.982605,23.723511],[-15.426025,24.359131],[-15.089294,24.520325],[-14.824585,25.103516],[-14.800903,25.636292],[-14.43988,26.254517],[-13.773804,26.618896],[-13.139893,27.640076],[-12.618774,28.03833],[-11.688904,28.148682],[-10.900879,28.832275],[-10.399597,29.098694],[-9.564819,29.933716],[-9.814697,31.177673],[-9.434814,32.038086],[-9.30072,32.564697],[-8.65741,33.240295],[-7.654175,33.697083],[-6.912476,34.110474],[-6.244324,35.145874],[-5.929993,35.760071],[-5.193787,35.75531],[-4.591003,35.330688],[-3.640076,35.399902],[-2.604309,35.179077],[-2.169922,35.168518],[-1.208618,35.714905],[-0.12738,35.888672],[0.503906,36.301331],[1.466919,36.605713],[3.161682,36.783875],[4.815674,36.865112],[5.320129,36.716492],[6.261902,37.110718],[7.330505,37.11853],[7.737122,36.885681],[8.421082,36.946472],[9.510071,37.350098],[10.210083,37.230103],[10.180725,36.724121],[11.028931,37.092102],[11.100098,36.900085],[10.600098,36.410095],[10.593323,35.94751],[10.939514,35.699097],[10.807922,34.833496],[10.149719,34.330688],[10.339722,33.785889],[10.856873,33.768677],[11.108521,33.293274],[11.488892,33.137085],[12.66333,32.792908],[13.083313,32.878906],[13.918701,32.712097],[15.245728,32.265076],[15.713928,31.376282],[16.611694,31.182312],[18.021118,30.763489],[19.086487,30.266479],[19.574097,30.525879],[20.053284,30.985901],[19.820312,31.751892],[20.134094,32.238281],[20.854492,32.706909],[21.543091,32.843323],[22.895874,32.638489],[23.236877,32.191528],[23.609131,32.187317],[23.92749,32.016724],[24.921082,31.899475],[25.164917,31.569275],[26.4953,31.585693],[27.457703,31.321289],[28.4505,31.025879],[28.913513,30.870117],[29.683472,31.18689],[30.095093,31.473511],[30.976929,31.555908],[31.68811,31.429688],[31.96051,30.933716],[32.192505,31.260315],[32.993896,31.024109],[33.773499,30.967529],[34.265503,31.219482],[34.556519,31.548889],[34.488098,31.60553],[34.752686,32.072876],[34.955505,32.827515],[35.098511,33.080688],[35.126099,33.090881],[35.4823,33.905518],[35.979675,34.610107],[35.998474,34.644897],[35.90509,35.410095],[36.149902,35.821472],[35.782104,36.275085],[36.160889,36.650696],[35.551086,36.565491],[34.714478,36.795471],[34.026917,36.220093],[32.509277,36.107483],[31.699707,36.644287],[30.621704,36.677917],[30.391113,36.263123],[29.700073,36.144287],[28.73291,36.67688],[27.641296,36.658875],[27.048889,37.653503],[26.318298,38.20813],[26.804688,38.985901],[26.170898,39.463684],[27.28009,40.420105],[28.820129,40.460083],[29.240112,41.220093],[31.145874,41.087708],[32.348083,41.736328],[33.513306,42.019104],[35.167725,42.040283],[36.913086,41.33551],[38.347717,40.94873],[39.512695,41.102905],[40.373474,41.013672],[41.554077,41.535706],[41.703308,41.963074],[41.453491,42.645081],[40.875488,43.013672],[40.321472,43.128723],[39.955078,43.43512],[38.680115,44.28009],[37.539124,44.657288],[36.675476,45.24469],[37.40332,45.40448],[38.233093,46.240906],[37.673706,46.636719],[39.147705,47.044678],[39.121277,47.263489],[38.223694,47.102295],[37.42511,47.022278],[36.759888,46.69873],[35.82373,46.645874],[34.96228,46.273315],[35.020874,45.651306],[35.510071,45.410095],[36.53009,45.470093],[36.334717,45.113281],[35.240112,44.940125],[33.882507,44.361511],[33.326477,44.56488],[33.546875,45.034912],[32.454285,45.327515],[32.63092,45.519287],[33.588074,45.851685],[33.298706,46.080688],[31.74408,46.333496],[31.675293,46.706299],[30.748901,46.58313],[30.377686,46.032471],[29.603271,45.293274],[29.626526,45.035522],[29.141724,44.820312],[28.837891,44.913879],[28.558105,43.70752],[28.039124,43.293274],[27.673889,42.577881],[27.996704,42.007507],[28.115479,41.622925],[28.988525,41.299927],[28.806519,41.054871],[27.61908,40.999878],[27.192505,40.690674],[26.358093,40.1521],[26.043274,40.617676],[26.056885,40.824097],[25.447693,40.852478],[24.925903,40.947083],[23.714905,40.687073],[24.408081,40.125122],[23.900085,39.962097],[23.343079,39.961121],[22.814087,40.476074],[22.626282,40.256531],[22.84967,39.659302],[23.350098,39.190125],[22.973083,38.970886],[23.53009,38.510071],[24.025085,38.220093],[24.0401,37.65509],[23.115112,37.920105],[23.410095,37.410095],[22.775085,37.305115],[23.154297,36.422485],[22.490112,36.410095],[21.670105,36.845093],[21.295105,37.645081],[21.120117,38.310303],[20.730103,38.770081],[20.217712,39.340271],[20.150085,39.625122],[19.980103,39.695129],[19.960083,39.9151],[19.406128,40.250916],[19.319092,40.727295],[19.403687,41.409485],[19.5401,41.720093],[19.371887,41.877686],[19.162476,41.955078],[18.88208,42.281494],[18.450073,42.480103],[17.509888,42.850098],[16.930115,43.210083],[16.015503,43.507324],[15.1745,44.243286],[15.376282,44.317871],[14.920288,44.738525],[14.901672,45.076111],[14.258728,45.233887],[13.952271,44.802124],[13.657104,45.137085],[13.679504,45.484131],[13.715088,45.500305],[13.937683,45.591125],[13.141724,45.736694],[12.328674,45.381897],[12.383911,44.885498],[12.261475,44.600525],[12.589294,44.091492],[13.526917,43.587708],[14.029907,42.761108],[15.1427,41.955078],[15.926331,41.961304],[16.169922,41.740295],[15.889282,41.541077],[16.785095,41.179688],[17.519287,40.877075],[18.376709,40.355713],[18.480286,40.168884],[18.293518,39.810913],[17.738525,40.27771],[16.86969,40.442322],[16.44873,39.795471],[17.171509,39.424683],[17.052917,38.902893],[16.635071,38.843689],[16.101074,37.985901],[15.684082,37.908875],[15.68811,38.214722],[15.89209,38.750916],[16.109314,38.964478],[15.718872,39.544128],[15.413696,40.048279],[14.998474,40.173096],[14.703308,40.604675],[14.06073,40.786499],[13.628113,41.188293],[12.888123,41.253113],[12.106689,41.704529],[11.191895,42.35553],[10.512085,42.931519],[10.200073,43.920105],[9.702515,44.036316],[8.888916,44.366272],[8.428711,44.231323],[7.850891,43.767273],[7.435303,43.693909],[6.529297,43.128906],[4.556885,43.399719],[3.100525,43.075317],[2.986084,42.473083],[3.03949,41.89209],[2.091919,41.226074],[0.810486,41.014709],[0.721313,40.678284],[0.106689,40.124084],[-0.278687,39.31012],[0.111328,38.738525],[-0.467102,38.29248],[-0.683411,37.642273],[-1.438293,37.443115],[-2.146423,36.674072],[-3.41571,36.658875],[-4.368896,36.677917],[-4.995178,36.324707],[-5.377075,35.946899],[-5.866394,36.029907],[-6.236694,36.367676],[-6.520203,36.942871],[-7.453674,37.0979],[-7.855591,36.838318],[-8.382812,36.978882],[-8.898804,36.868896],[-8.746094,37.651489],[-8.840027,38.266296],[-9.287476,38.358521],[-9.526489,38.737488],[-9.446899,39.39209],[-9.048279,39.755127],[-8.977295,40.159302],[-8.768677,40.760681],[-8.790771,41.184326],[-8.990784,41.543518],[-9.03479,41.880676],[-8.984375,42.592896],[-9.392883,43.026672],[-7.97821,43.748474],[-6.754517,43.567871],[-5.411804,43.57428],[-4.347778,43.403503],[-3.517517,43.455872],[-1.901306,43.422913],[-1.384216,44.022705],[-1.193787,46.014893],[-2.225708,47.064514],[-2.963196,47.570312],[-4.491577,47.955078],[-4.592285,48.684082],[-3.295776,48.901672],[-1.616516,48.64447],[-1.933411,49.776489],[-0.98938,49.347473],[1.338684,50.127319],[1.639099,50.946716],[2.513489,51.148499],[3.315125,51.345886],[3.830322,51.620483],[4.706116,53.091919],[6.07428,53.510498],[6.905273,53.4823],[7.100525,53.693909],[7.936279,53.748291],[8.121704,53.527893],[8.80072,54.020874],[8.572083,54.395691],[8.526306,54.962891],[8.1203,55.5177],[8.090088,56.5401],[8.256714,56.81012],[8.543518,57.110107],[9.4245,57.172119],[9.775696,57.447876],[10.580078,57.730103],[10.546082,57.215881],[10.250122,56.890076],[10.370117,56.610107],[10.912292,56.458679],[10.667908,56.081482],[10.370117,56.190125],[9.650085,55.470093],[9.921875,54.983093],[9.939697,54.59668],[10.950073,54.363708],[10.939514,54.008728],[11.956299,54.196472],[12.518494,54.47052],[13.647522,54.0755],[14.11969,53.75708],[14.802917,54.05072],[16.363525,54.513306],[17.622925,54.851685],[18.620911,54.682678],[18.696289,54.438721],[19.660706,54.426086],[19.888489,54.866089],[21.268494,55.190491],[21.055908,56.031128],[21.090515,56.783875],[21.581909,57.411926],[22.524475,57.753479],[23.318481,57.006287],[24.120728,57.025696],[24.312927,57.793518],[24.429077,58.383484],[24.061279,58.257507],[23.426697,58.612671],[23.339905,59.187317],[24.604309,59.465881],[25.864319,59.611084],[26.94928,59.445923],[27.981079,59.475525],[29.117676,60.028076],[28.070129,60.503479],[26.25531,60.423889],[24.496704,60.057312],[22.86969,59.846497],[22.290894,60.391907],[21.322327,60.720276],[21.544922,61.705322],[21.059326,62.607483],[21.536072,63.18988],[22.442688,63.817871],[24.73053,64.902283],[25.398071,65.111511],[25.294128,65.534485],[23.903503,66.006897],[22.183289,65.723877],[21.213501,65.026123],[21.36969,64.413696],[19.778931,63.60968],[17.8479,62.749512],[17.11969,61.341309],[17.831482,60.636719],[18.78772,60.081909],[17.869324,58.953918],[16.829285,58.71991],[16.447693,57.041077],[15.879883,56.104309],[14.666687,56.200928],[14.100708,55.407898],[12.942871,55.361877],[12.625122,56.307129],[11.788086,57.441895],[11.027283,58.856079],[10.356689,59.46991],[8.38208,58.313293],[7.048889,58.078918],[5.665894,58.588074],[5.308289,59.66333],[4.992126,61.97113],[5.912903,62.614502],[8.553528,63.454102],[10.52771,64.486084],[12.358276,65.8797],[14.761292,67.81073],[16.435913,68.563293],[19.184082,69.817505],[21.378479,70.25531],[23.023682,70.202087],[24.546692,71.030518],[26.370117,70.986328],[28.165527,71.185486],[31.293518,70.453918],[30.005493,70.186279],[31.101074,69.558105],[32.13269,69.905884],[33.775513,69.301514],[36.514099,69.063477],[40.29248,67.932495],[41.059875,67.457275],[41.126099,66.791687],[40.01593,66.266296],[38.382874,65.999512],[33.918701,66.759705],[33.184509,66.632507],[34.81488,65.900085],[34.943909,64.41449],[36.231323,64.109497],[37.012878,63.849915],[37.14209,64.334717],[36.5177,64.780273],[37.176086,65.143311],[39.593506,64.520874],[40.43573,64.764526],[39.762695,65.496887],[42.093079,66.476318],[43.016113,66.418701],[43.94989,66.069092],[44.532288,66.756287],[43.698486,67.352478],[44.187927,67.9505],[43.452881,68.570923],[46.250122,68.250122],[46.821289,67.68988],[45.555298,67.566528],[45.562073,67.010071],[46.349121,66.667725],[47.894287,66.884521],[48.138672,67.522522],[50.227722,67.998718],[53.717529,68.857483],[54.47168,68.808289],[53.485901,68.201294],[54.726318,68.097107],[55.442688,68.438721],[57.317078,68.466309],[58.802124,68.88092],[59.941528,68.278503],[61.077881,68.940674],[60.03009,69.520081],[60.55011,69.850098],[63.504089,69.547485],[64.888123,69.234924],[68.512085,68.092285],[69.180725,68.615723],[68.16449,69.144287],[68.135315,69.356506],[66.930115,69.454712],[67.259888,69.928711],[66.724915,70.708923],[66.694702,71.029114],[68.5401,71.934509],[69.196289,72.843506],[69.940125,73.0401],[72.587524,72.776306],[72.796082,72.220093],[71.848083,71.409119],[72.470093,71.090271],[72.79187,70.391113],[72.564697,69.020874],[73.667908,68.407898],[73.238708,67.740479],[71.28009,66.320129],[72.423096,66.172729],[72.820679,66.532715],[73.921082,66.78949],[74.186523,67.284302],[75.052124,67.760498],[74.469299,68.329102],[74.935913,68.989319],[73.842285,69.071472],[73.601929,69.627686],[74.399902,70.631897],[73.101074,71.447083],[74.89093,72.121277],[74.659302,72.832275],[75.158081,72.855103],[75.683472,72.300476],[75.289124,71.335693],[76.359131,71.152893],[75.903076,71.874084],[77.576721,72.267273],[79.6521,72.320129],[81.500122,71.750122],[80.610718,72.582886],[80.511108,73.648315],[82.250122,73.850098],[84.655273,73.805908],[86.822327,73.93689],[86.009705,74.459717],[87.16687,75.116516],[88.315674,75.143921],[90.260071,75.640076],[92.900696,75.773315],[93.234314,76.047302],[95.860107,76.140076],[96.678284,75.915527],[98.922485,76.446899],[100.759705,76.430298],[101.035278,76.861877],[101.990906,77.287476],[104.351685,77.697876],[106.066711,77.373901],[104.705078,77.127502],[106.970276,76.974304]],[[49.110291,41.282288],[49.618896,40.572876],[50.0849,40.526306],[50.392883,40.256531],[49.569275,40.176086],[49.395325,39.399475],[49.223328,39.049316],[48.856506,38.815491],[48.883301,38.320313],[49.199707,37.582886],[50.147888,37.374695],[50.842285,36.872925],[52.264099,36.7005],[53.825928,36.965088],[53.921692,37.198914],[53.735474,37.906128],[53.88092,38.952087],[53.101074,39.29071],[53.35791,39.975281],[52.694092,40.033691],[52.915283,40.876526],[53.858276,40.631104],[54.736877,40.951111],[54.008301,41.551331],[53.72168,42.123291],[52.916687,41.868103],[52.814697,41.135498],[52.502502,41.783325],[52.446289,42.027283],[52.692078,42.443909],[52.501526,42.792297],[51.342529,43.133118],[50.891296,44.031128],[50.339111,44.284119],[50.305725,44.609924],[51.278503,44.514893],[51.316895,45.246094],[52.16748,45.408508],[53.040894,45.259094],[53.220886,46.23468],[53.042725,46.853088],[52.042114,46.804687],[51.192078,47.048706],[50.034119,46.609131],[49.101318,46.399475],[48.645508,45.806274],[47.675903,45.641479],[46.682129,44.609314],[47.590881,43.660278],[47.492493,42.986694],[48.584473,41.808899],[49.110291,41.282288]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[-96.436304,77.491343,-93.720656,77.834629],"geometry":{"type":"Polygon","coordinates":[[[-93.840003,77.519997],[-94.295608,77.491343],[-96.169654,77.555111],[-96.436304,77.834629],[-94.422577,77.820005],[-93.720656,77.634331],[-93.840003,77.519997]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-113.534279,77.409229,-109.854452,78.152956],"geometry":{"type":"Polygon","coordinates":[[[-110.186938,77.697015],[-112.051191,77.409229],[-113.534279,77.732207],[-112.724587,78.05105],[-111.264443,78.152956],[-109.854452,77.996325],[-110.186938,77.697015]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[20.72601,77.44493,24.72412,78.45494],"geometry":{"type":"Polygon","coordinates":[[[24.72412,77.85385],[22.49032,77.44493],[20.72601,77.67704],[21.41611,77.93504],[20.8119,78.25463],[22.88426,78.45494],[23.28134,78.07954],[24.72412,77.85385]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1.5},"bbox":[-112.542091,78.40692,-109.663146,78.849994],"geometry":{"type":"Polygon","coordinates":[[[-109.663146,78.601973],[-110.881314,78.40692],[-112.542091,78.407902],[-112.525891,78.550555],[-111.50001,78.849994],[-110.963661,78.804441],[-109.663146,78.601973]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[-98.631984,77.850597,-95.559278,78.87193],"geometry":{"type":"Polygon","coordinates":[[[-95.830295,78.056941],[-97.309843,77.850597],[-98.124289,78.082857],[-98.552868,78.458105],[-98.631984,78.87193],[-97.337231,78.831984],[-96.754399,78.765813],[-95.559278,78.418315],[-95.830295,78.056941]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":0.5},"bbox":[-105.492289,77.907545,-99.670939,79.301594],"geometry":{"type":"Polygon","coordinates":[[[-100.060192,78.324754],[-99.670939,77.907545],[-101.30394,78.018985],[-102.949809,78.343229],[-105.176133,78.380332],[-104.210429,78.67742],[-105.41958,78.918336],[-105.492289,79.301594],[-103.529282,79.165349],[-100.825158,78.800462],[-100.060192,78.324754]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0.5},"bbox":[99.43814,77.921,105.37243,79.34641],"geometry":{"type":"Polygon","coordinates":[[[105.07547,78.30689],[99.43814,77.921],[101.2649,79.23399],[102.08635,79.34641],[102.837815,79.28129],[105.37243,78.71334],[105.07547,78.30689]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[10.44453,76.77045,21.54383,80.05086],"geometry":{"type":"Polygon","coordinates":[[[18.25183,79.70175],[21.54383,78.95611],[19.02737,78.5626],[18.47172,77.82669],[17.59441,77.63796],[17.1182,76.80941],[15.91315,76.77045],[13.76259,77.38035],[14.66956,77.73565],[13.1706,78.02493],[11.22231,78.8693],[10.44453,79.65239],[13.17077,80.01046],[13.71852,79.66039],[15.14282,79.67431],[15.52255,80.01608],[16.99085,80.05086],[18.25183,79.70175]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[17.368015,79.400012,27.407506,80.657144],"geometry":{"type":"Polygon","coordinates":[[[25.447625,80.40734],[27.407506,80.056406],[25.924651,79.517834],[23.024466,79.400012],[20.075188,79.566823],[19.897266,79.842362],[18.462264,79.85988],[17.368015,80.318896],[20.455992,80.598156],[21.907945,80.357679],[22.919253,80.657144],[25.447625,80.40734]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":1,"min_zoom":1},"bbox":[44.846958,80.010181,51.522933,80.918885],"geometry":{"type":"Polygon","coordinates":[[[51.136187,80.54728],[49.793685,80.415428],[48.894411,80.339567],[48.754937,80.175468],[47.586119,80.010181],[46.502826,80.247247],[47.072455,80.559424],[44.846958,80.58981],[46.799139,80.771918],[48.318477,80.78401],[48.522806,80.514569],[49.09719,80.753986],[50.039768,80.918885],[51.522933,80.699726],[51.136187,80.54728]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[91.18107,78.7562,100.186655,81.2504],"geometry":{"type":"Polygon","coordinates":[[[99.93976,78.88094],[97.75794,78.7562],[94.97259,79.044745],[93.31288,79.4265],[92.5454,80.14379],[91.18107,80.34146],[93.77766,81.0246],[95.940895,81.2504],[97.88385,80.746975],[100.186655,79.780135],[99.93976,78.88094]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-96.70972,78.21533,-85.81435,81.25739],"geometry":{"type":"Polygon","coordinates":[[[-87.02,79.66],[-85.81435,79.3369],[-87.18756,79.0393],[-89.03535,78.28723],[-90.80436,78.21533],[-92.87669,78.34333],[-93.95116,78.75099],[-93.93574,79.11373],[-93.14524,79.3801],[-94.974,79.37248],[-96.07614,79.70502],[-96.70972,80.15777],[-96.01644,80.60233],[-95.32345,80.90729],[-94.29843,80.97727],[-94.73542,81.20646],[-92.40984,81.25739],[-91.13289,80.72345],[-89.45,80.509322],[-87.81,80.32],[-87.02,79.66]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-91.58702,76.17812,-61.85,83.23324],"geometry":{"type":"Polygon","coordinates":[[[-68.5,83.106322],[-65.82735,83.02801],[-63.68,82.9],[-61.85,82.6286],[-61.89388,82.36165],[-64.334,81.92775],[-66.75342,81.72527],[-67.65755,81.50141],[-65.48031,81.50657],[-67.84,80.9],[-69.4697,80.61683],[-71.18,79.8],[-73.2428,79.63415],[-73.88,79.430162],[-76.90773,79.32309],[-75.52924,79.19766],[-76.22046,79.01907],[-75.39345,78.52581],[-76.34354,78.18296],[-77.88851,77.89991],[-78.36269,77.50859],[-79.75951,77.20968],[-79.61965,76.98336],[-77.91089,77.022045],[-77.88911,76.777955],[-80.56125,76.17812],[-83.17439,76.45403],[-86.11184,76.29901],[-87.6,76.42],[-89.49068,76.47239],[-89.6161,76.95213],[-87.76739,77.17833],[-88.26,77.9],[-87.65,77.970222],[-84.97634,77.53873],[-86.34,78.18],[-87.96192,78.37181],[-87.15198,78.75867],[-85.37868,78.9969],[-85.09495,79.34543],[-86.50734,79.73624],[-86.93179,80.25145],[-84.19844,80.20836],[-83.408696,80.1],[-81.84823,80.46442],[-84.1,80.58],[-87.59895,80.51627],[-89.36663,80.85569],[-90.2,81.26],[-91.36786,81.5531],[-91.58702,81.89429],[-90.1,82.085],[-88.93227,82.11751],[-86.97024,82.27961],[-85.5,82.652273],[-84.260005,82.6],[-83.18,82.32],[-82.42,82.86],[-81.1,83.02],[-79.30664,83.13056],[-76.25,83.172059],[-75.71878,83.06404],[-72.83153,83.23324],[-70.665765,83.169781],[-68.5,83.106322]]]}},{"type":"Feature","properties":{"featurecla":"Land","scalerank":0,"min_zoom":0},"bbox":[-73.297,60.03676,-12.20855,83.64513],"geometry":{"type":"Polygon","coordinates":[[[-27.10046,83.51966],[-20.84539,82.72669],[-22.69182,82.34165],[-26.51753,82.29765],[-31.9,82.2],[-31.39646,82.02154],[-27.85666,82.13178],[-24.84448,81.78697],[-22.90328,82.09317],[-22.07175,81.73449],[-23.16961,81.15271],[-20.62363,81.52462],[-15.76818,81.91245],[-12.77018,81.71885],[-12.20855,81.29154],[-16.28533,80.58004],[-16.85,80.35],[-20.04624,80.17708],[-17.73035,80.12912],[-18.9,79.4],[-19.70499,78.75128],[-19.67353,77.63859],[-18.47285,76.98565],[-20.03503,76.94434],[-21.67944,76.62795],[-19.83407,76.09808],[-19.59896,75.24838],[-20.66818,75.15585],[-19.37281,74.29561],[-21.59422,74.22382],[-20.43454,73.81713],[-20.76234,73.46436],[-22.17221,73.30955],[-23.56593,73.30663],[-22.31311,72.62928],[-22.29954,72.18409],[-24.27834,72.59788],[-24.79296,72.3302],[-23.44296,72.08016],[-22.13281,71.46898],[-21.75356,70.66369],[-23.53603,70.471],[-24.30702,70.85649],[-25.54341,71.43094],[-25.20135,70.75226],[-26.36276,70.22646],[-23.72742,70.18401],[-22.34902,70.12946],[-25.02927,69.2588],[-27.74737,68.47046],[-30.67371,68.12503],[-31.77665,68.12078],[-32.81105,67.73547],[-34.20196,66.67974],[-36.35284,65.9789],[-37.04378,65.93768],[-38.37505,65.69213],[-39.81222,65.45848],[-40.66899,64.83997],[-40.68281,64.13902],[-41.1887,63.48246],[-42.81938,62.68233],[-42.41666,61.90093],[-42.86619,61.07404],[-43.3784,60.09772],[-44.7875,60.03676],[-46.26364,60.85328],[-48.26294,60.85843],[-49.23308,61.40681],[-49.90039,62.38336],[-51.63325,63.62691],[-52.14014,64.27842],[-52.27659,65.1767],[-53.66166,66.09957],[-53.30161,66.8365],[-53.96911,67.18899],[-52.9804,68.35759],[-51.47536,68.72958],[-51.08041,69.14781],[-50.87122,69.9291],[-52.013585,69.574925],[-52.55792,69.42616],[-53.45629,69.283625],[-54.68336,69.61003],[-54.75001,70.28932],[-54.35884,70.821315],[-53.431315,70.835755],[-51.39014,70.56978],[-53.10937,71.20485],[-54.00422,71.54719],[-55,71.406537],[-55.83468,71.65444],[-54.71819,72.58625],[-55.32634,72.95861],[-56.12003,73.64977],[-57.32363,74.71026],[-58.59679,75.09861],[-58.58516,75.51727],[-61.26861,76.10238],[-63.39165,76.1752],[-66.06427,76.13486],[-68.50438,76.06141],[-69.66485,76.37975],[-71.40257,77.00857],[-68.77671,77.32312],[-66.76397,77.37595],[-71.04293,77.63595],[-73.297,78.04419],[-73.15938,78.43271],[-69.37345,78.91388],[-65.7107,79.39436],[-65.3239,79.75814],[-68.02298,80.11721],[-67.15129,80.51582],[-63.68925,81.21396],[-62.23444,81.3211],[-62.65116,81.77042],[-60.28249,82.03363],[-57.20744,82.19074],[-54.13442,82.19962],[-53.04328,81.88833],[-50.39061,82.43883],[-48.00386,82.06481],[-46.59984,81.985945],[-44.523,81.6607],[-46.9007,82.19979],[-46.76379,82.62796],[-43.40644,83.22516],[-39.89753,83.18018],[-38.62214,83.54905],[-35.08787,83.64513],[-27.10046,83.51966]]]}}],"bbox":[-180,-90,180,83.64513]}
diff --git a/frontend/src/__tests__/account-auth.test.tsx b/frontend/src/__tests__/account-auth.test.tsx
new file mode 100644
index 00000000..cc892978
--- /dev/null
+++ b/frontend/src/__tests__/account-auth.test.tsx
@@ -0,0 +1,170 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import AccountPage from "@/app/account/page";
+import LoginPage from "@/app/auth/login/page";
+import SetupPage from "@/app/auth/setup/page";
+
+const api = vi.hoisted(() => ({
+ getAuthStatus: vi.fn(),
+ loginAccount: vi.fn(),
+ setupAccount: vi.fn(),
+ getCurrentAccount: vi.fn(),
+ getAccountSessions: vi.fn(),
+ updateAccountProfile: vi.fn(),
+ changeAccountPassword: vi.fn(),
+ revokeAccountSession: vi.fn(),
+ logoutAccount: vi.fn(),
+ extractErrorMessage: vi.fn((_error: unknown, fallback: string) => fallback),
+}));
+
+const router = vi.hoisted(() => ({ replace: vi.fn(), push: vi.fn() }));
+
+vi.mock("@/lib/api", () => api);
+vi.mock("next/navigation", () => ({ useRouter: () => router }));
+
+function renderPage(ui: React.ReactElement) {
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ return render(
+ {ui} ,
+ );
+}
+
+beforeEach(() => {
+ for (const fn of Object.values(api)) {
+ fn.mockReset();
+ }
+ api.extractErrorMessage.mockImplementation(
+ (_error: unknown, fallback: string) => fallback,
+ );
+ router.replace.mockReset();
+ router.push.mockReset();
+});
+
+afterEach(cleanup);
+
+describe("account bootstrap", () => {
+ it("lets a local instance continue without an account", async () => {
+ api.getAuthStatus.mockResolvedValue({
+ mode: "local",
+ setup_available: true,
+ });
+ renderPage( );
+
+ expect(
+ await screen.findByRole("link", { name: /continue locally/i }),
+ ).toHaveAttribute("href", "/timeline");
+ expect(
+ screen.getByRole("link", { name: /enable accounts/i }),
+ ).toHaveAttribute("href", "/auth/setup");
+ });
+
+ it("signs a shared-mode user in with a cookie-backed session", async () => {
+ api.getAuthStatus.mockResolvedValue({
+ mode: "shared",
+ setup_available: false,
+ });
+ api.loginAccount.mockResolvedValue({
+ user: {
+ id: 1,
+ username: "owner",
+ display_name: "Owner",
+ role: "admin",
+ },
+ });
+ renderPage( );
+
+ fireEvent.change(await screen.findByLabelText("Username"), {
+ target: { value: "owner" },
+ });
+ fireEvent.change(screen.getByLabelText("Password"), {
+ target: { value: "password123" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
+
+ await waitFor(() =>
+ expect(api.loginAccount).toHaveBeenCalledWith(
+ {
+ username: "owner",
+ password: "password123",
+ },
+ expect.anything(),
+ ),
+ );
+ await waitFor(() =>
+ expect(router.replace).toHaveBeenCalledWith("/timeline"),
+ );
+ });
+
+ it("does not submit setup while password confirmation differs", async () => {
+ api.getAuthStatus.mockResolvedValue({
+ mode: "local",
+ setup_available: true,
+ });
+ renderPage( );
+
+ fireEvent.change(await screen.findByLabelText("Password"), {
+ target: { value: "password123" },
+ });
+ fireEvent.change(screen.getByLabelText("Confirm password"), {
+ target: { value: "password456" },
+ });
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Passwords do not match",
+ );
+ expect(
+ screen.getByRole("button", { name: "Create account" }),
+ ).toBeDisabled();
+ expect(api.setupAccount).not.toHaveBeenCalled();
+ });
+});
+
+describe("account settings", () => {
+ it("explains local mode without inventing a user", async () => {
+ api.getCurrentAccount.mockResolvedValue({ mode: "local", user: null });
+ renderPage( );
+
+ expect(
+ await screen.findByRole("heading", { name: "Private local mode" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders profile and server-side sessions for a shared account", async () => {
+ api.getCurrentAccount.mockResolvedValue({
+ mode: "shared",
+ user: {
+ id: 1,
+ username: "owner",
+ display_name: "Find Owner",
+ role: "admin",
+ },
+ });
+ api.getAccountSessions.mockResolvedValue([
+ {
+ id: 9,
+ created_at: "2026-07-12T00:00:00Z",
+ expires_at: "2026-07-13T00:00:00Z",
+ current: true,
+ },
+ ]);
+ renderPage( );
+
+ expect(
+ await screen.findByRole("heading", { name: "Account settings" }),
+ ).toBeInTheDocument();
+ expect(screen.getByDisplayValue("Find Owner")).toBeInTheDocument();
+ expect(await screen.findByText("This browser")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/album-detail-viewer.test.tsx b/frontend/src/__tests__/album-detail-viewer.test.tsx
index 1f547d4f..d8f3b6bc 100644
--- a/frontend/src/__tests__/album-detail-viewer.test.tsx
+++ b/frontend/src/__tests__/album-detail-viewer.test.tsx
@@ -30,6 +30,25 @@ const api = vi.hoisted(() => ({
deleteSharedLink: vi.fn(),
}));
+class FakeResizeObserver {
+ constructor(private callback: ResizeObserverCallback) {}
+
+ observe(target: Element) {
+ this.callback(
+ [
+ {
+ target,
+ contentRect: { width: 1000, height: 0 } as DOMRectReadOnly,
+ } as ResizeObserverEntry,
+ ],
+ this as unknown as ResizeObserver,
+ );
+ }
+
+ unobserve() {}
+ disconnect() {}
+}
+
vi.mock("@/lib/api", () => api);
vi.mock("@/lib/media", () => ({ resolveMediaUrl: (u: string) => u }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
@@ -80,6 +99,8 @@ beforeEach(() => {
total: 2,
});
api.getSharedLinks.mockResolvedValue({ shared_links: [], total: 0 });
+ vi.stubGlobal("ResizeObserver", FakeResizeObserver);
+ vi.stubGlobal("innerHeight", 5000);
vi.stubGlobal(
"Image",
class {
diff --git a/frontend/src/__tests__/app-shell.test.tsx b/frontend/src/__tests__/app-shell.test.tsx
new file mode 100644
index 00000000..fe839b8a
--- /dev/null
+++ b/frontend/src/__tests__/app-shell.test.tsx
@@ -0,0 +1,171 @@
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ within,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AppShell } from "@/components/app-shell";
+
+const navigation = vi.hoisted(() => ({
+ pathname: "/timeline",
+ push: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => navigation.pathname,
+ useRouter: () => ({ push: navigation.push }),
+}));
+
+beforeEach(() => {
+ navigation.pathname = "/timeline";
+ navigation.push.mockReset();
+ localStorage.clear();
+});
+
+afterEach(() => {
+ cleanup();
+ document.documentElement.classList.remove("light", "dark");
+ delete document.documentElement.dataset.theme;
+ document.documentElement.style.colorScheme = "";
+ document.body.style.overflow = "";
+});
+
+describe("AppShell", () => {
+ it("renders the private route groups and top-bar actions", () => {
+ render(
+
+ Timeline content
+ ,
+ );
+
+ expect(screen.getByRole("banner")).toBeInTheDocument();
+ expect(screen.getByText("Timeline content")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "FIND. Photos" })).toHaveAttribute(
+ "href",
+ "/timeline",
+ );
+ expect(screen.getByRole("link", { name: "Photos" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+
+ for (const label of [
+ "Search",
+ "Map",
+ "People",
+ "Albums",
+ "Favorites",
+ "Duplicates",
+ "Clusters",
+ "Archive",
+ "Vault",
+ "Trash",
+ "Settings",
+ ]) {
+ expect(screen.getByRole("link", { name: label })).toBeInTheDocument();
+ }
+
+ expect(screen.getByRole("link", { name: "Upload" })).toHaveAttribute(
+ "href",
+ "/upload",
+ );
+ expect(screen.getByRole("link", { name: "Account" })).toHaveAttribute(
+ "href",
+ "/account",
+ );
+ expect(
+ screen.getByRole("button", { name: "Switch to dark mode" }),
+ ).toBeInTheDocument();
+ });
+
+ it.each([
+ "/public/shared/key",
+ "/auth/login",
+ "/auth/setup",
+ ])("does not expose private chrome on %s", (pathname) => {
+ navigation.pathname = pathname;
+ render(
+
+ Shell-free content
+ ,
+ );
+
+ expect(screen.getByText("Shell-free content")).toBeInTheDocument();
+ expect(screen.queryByRole("banner")).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("navigation", { name: "Main navigation" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("opens an accessible mobile drawer and closes it with Escape", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const trigger = screen.getByRole("button", {
+ name: "Open navigation menu",
+ });
+ fireEvent.click(trigger);
+
+ const drawer = screen.getByRole("dialog", { name: "Navigation menu" });
+ expect(drawer).toHaveAttribute("aria-hidden", "false");
+ expect(
+ within(drawer).getByRole("link", { name: "Photos" }),
+ ).toBeInTheDocument();
+
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(drawer).toHaveAttribute("aria-hidden", "true");
+ });
+
+ it("locks page scroll, traps focus, and restores the menu trigger", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const trigger = screen.getByRole("button", {
+ name: "Open navigation menu",
+ });
+ fireEvent.click(trigger);
+
+ const drawer = screen.getByRole("dialog", { name: "Navigation menu" });
+ const focusable = Array.from(
+ drawer.querySelectorAll(
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
+ ),
+ );
+ const first = focusable.at(0);
+ const last = focusable.at(-1);
+
+ expect(document.body.style.overflow).toBe("hidden");
+ expect(first).toHaveFocus();
+
+ last?.focus();
+ fireEvent.keyDown(window, { key: "Tab" });
+ expect(first).toHaveFocus();
+
+ first?.focus();
+ fireEvent.keyDown(window, { key: "Tab", shiftKey: true });
+ expect(last).toHaveFocus();
+
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(document.body.style.overflow).toBe("");
+ expect(trigger).toHaveFocus();
+ });
+
+ it("opens Search from the global keyboard shortcut", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ fireEvent.keyDown(window, { key: "/" });
+ expect(navigation.push).toHaveBeenCalledWith("/search");
+ });
+});
diff --git a/frontend/src/__tests__/archive-trash.test.tsx b/frontend/src/__tests__/archive-trash.test.tsx
index 71f87ee3..91b491be 100644
--- a/frontend/src/__tests__/archive-trash.test.tsx
+++ b/frontend/src/__tests__/archive-trash.test.tsx
@@ -24,6 +24,25 @@ const api = vi.hoisted(() => ({
emptyTrash: vi.fn(),
}));
+class FakeResizeObserver {
+ constructor(private callback: ResizeObserverCallback) {}
+
+ observe(target: Element) {
+ this.callback(
+ [
+ {
+ target,
+ contentRect: { width: 1000, height: 0 } as DOMRectReadOnly,
+ } as ResizeObserverEntry,
+ ],
+ this as unknown as ResizeObserver,
+ );
+ }
+
+ unobserve() {}
+ disconnect() {}
+}
+
vi.mock("@/lib/api", () => api);
vi.mock("@/lib/media", () => ({ resolveMediaUrl: (u: string) => u }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
@@ -56,9 +75,14 @@ const listResponse = (ids: number[]) => ({
beforeEach(() => {
for (const fn of Object.values(api)) fn.mockReset();
+ vi.stubGlobal("ResizeObserver", FakeResizeObserver);
+ vi.stubGlobal("innerHeight", 5000);
});
-afterEach(() => cleanup());
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
describe("TrashPage", () => {
it("shows empty state", async () => {
diff --git a/frontend/src/__tests__/asset-viewer.test.tsx b/frontend/src/__tests__/asset-viewer.test.tsx
index 7ef570dd..c7b5ae94 100644
--- a/frontend/src/__tests__/asset-viewer.test.tsx
+++ b/frontend/src/__tests__/asset-viewer.test.tsx
@@ -74,6 +74,39 @@ describe("AssetViewer", () => {
);
});
+ it("uses descriptive asset text and a safe fallback for image alternatives", () => {
+ const assets = [
+ { ...ASSETS[0], alt: "Sunset above the mountain ridge" },
+ ASSETS[1],
+ ];
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByTestId("viewer-image")).toHaveAttribute(
+ "alt",
+ "Sunset above the mountain ridge",
+ );
+
+ rerender(
+ ,
+ );
+ expect(screen.getByTestId("viewer-image")).toHaveAttribute(
+ "alt",
+ "Photo 1",
+ );
+ });
+
it("swaps to the original once it preloads", () => {
renderViewer(1);
// The active original preload is created; fire its onload.
diff --git a/frontend/src/__tests__/map-page.test.tsx b/frontend/src/__tests__/map-page.test.tsx
new file mode 100644
index 00000000..2570cf35
--- /dev/null
+++ b/frontend/src/__tests__/map-page.test.tsx
@@ -0,0 +1,169 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import MapPage from "@/app/map/page";
+import type { MapMarker, MapMarkersResponse } from "@/lib/api";
+
+const api = vi.hoisted(() => ({
+ getMapMarkers: vi.fn(),
+}));
+
+vi.mock("@/lib/api", () => ({
+ getMapMarkers: api.getMapMarkers,
+}));
+
+vi.mock("@/components/private-map", () => ({
+ PrivateMap: ({
+ markers,
+ onSelect,
+ }: {
+ markers: MapMarker[];
+ onSelect: (ids: number[]) => void;
+ }) => (
+
+ {markers.length} map markers
+ onSelect(markers.map(({ id }) => id))}
+ >
+ Select map cluster
+
+
+ ),
+}));
+
+const MARKERS: MapMarker[] = [
+ {
+ id: 1,
+ lat: 22.5726,
+ lon: 88.3639,
+ filename: "kolkata.jpg",
+ created_at: "2026-07-11T10:00:00Z",
+ thumbnail_url: "/api/image/1/thumbnail",
+ ratio: 1.5,
+ liked: true,
+ },
+ {
+ id: 2,
+ lat: 51.5072,
+ lon: -0.1276,
+ filename: "london.jpg",
+ created_at: "2026-06-05T10:00:00Z",
+ thumbnail_url: "/api/image/2/thumbnail",
+ ratio: 1,
+ liked: false,
+ },
+];
+
+function enabledResponse(markers = MARKERS): MapMarkersResponse {
+ return { enabled: true, markers, total: markers.length };
+}
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ api.getMapMarkers.mockReset();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe("MapPage", () => {
+ it("shows the opt-in state and links directly to map privacy settings", async () => {
+ api.getMapMarkers.mockResolvedValue({
+ enabled: false,
+ markers: [],
+ total: 0,
+ } satisfies MapMarkersResponse);
+
+ renderPage();
+
+ const disabled = await screen.findByTestId("map-disabled");
+ expect(disabled).toHaveTextContent(/off/i);
+ expect(
+ screen.getByRole("link", { name: /open map privacy settings/i }),
+ ).toHaveAttribute("href", "/settings#private-map");
+ expect(screen.queryByTestId("private-map")).toBeNull();
+ });
+
+ it("loads clustered-map data and applies favorites and archive filters", async () => {
+ api.getMapMarkers.mockResolvedValue(enabledResponse());
+ renderPage();
+
+ expect(await screen.findByTestId("private-map")).toHaveTextContent(
+ "2 map markers",
+ );
+ fireEvent.click(screen.getByTestId("map-favorites-filter"));
+
+ await waitFor(() =>
+ expect(api.getMapMarkers).toHaveBeenCalledWith({
+ liked: true,
+ includeArchived: false,
+ }),
+ );
+
+ fireEvent.click(screen.getByTestId("map-archive-filter"));
+ await waitFor(() =>
+ expect(api.getMapMarkers).toHaveBeenCalledWith({
+ liked: true,
+ includeArchived: true,
+ }),
+ );
+ });
+
+ it("opens a month timeline for a selected cluster and uses original media in the viewer", async () => {
+ const preloaded: string[] = [];
+ vi.stubGlobal(
+ "Image",
+ class {
+ onload: (() => void) | null = null;
+ set src(value: string) {
+ preloaded.push(value);
+ }
+ },
+ );
+ api.getMapMarkers.mockResolvedValue(enabledResponse());
+ renderPage();
+
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Select map cluster" }),
+ );
+
+ expect(screen.getByTestId("map-timeline-panel")).toHaveTextContent(
+ "2 photos",
+ );
+ expect(screen.getByText("July 2026")).toBeInTheDocument();
+ expect(screen.getByText("June 2026")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("map-timeline-photo-1"));
+ expect(screen.getByTestId("asset-viewer")).toBeInTheDocument();
+ await waitFor(() => expect(preloaded).toContain("/api/image/1/original"));
+ });
+
+ it("renders a useful empty state without hiding the offline world map", async () => {
+ api.getMapMarkers.mockResolvedValue(enabledResponse([]));
+ renderPage();
+
+ expect(await screen.findByTestId("private-map")).toBeInTheDocument();
+ expect(screen.getByTestId("map-empty")).toHaveTextContent(
+ /no matching photos/i,
+ );
+ });
+});
diff --git a/frontend/src/__tests__/media-timeline.test.ts b/frontend/src/__tests__/media-timeline.test.ts
new file mode 100644
index 00000000..8781b819
--- /dev/null
+++ b/frontend/src/__tests__/media-timeline.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import {
+ actualOffsetToScrubberOffset,
+ groupMediaByMonth,
+ mediaAspectRatio,
+ scrubberOffsetToActualOffset,
+ timelineBucketsFromGroups,
+} from "@/lib/media-timeline";
+import { buildScrubberLayout } from "@/lib/timeline-scrubber";
+
+interface Item {
+ id: number;
+ createdAt: string | null;
+}
+
+describe("media timeline grouping", () => {
+ it("groups and orders media newest-first while retaining undated items", () => {
+ const items: Item[] = [
+ { id: 1, createdAt: "2026-02-01T00:00:00Z" },
+ { id: 2, createdAt: "2026-03-02T00:00:00Z" },
+ { id: 3, createdAt: "2026-03-20T00:00:00Z" },
+ { id: 4, createdAt: null },
+ ];
+
+ const groups = groupMediaByMonth(items, (item) => item.createdAt);
+
+ expect(groups.map((group) => group.timeBucket)).toEqual([
+ "2026-03-01",
+ "2026-02-01",
+ "undated",
+ ]);
+ expect(groups[0]?.items.map((item) => item.id)).toEqual([3, 2]);
+ expect(groups[2]?.label).toBe("Undated");
+ expect(timelineBucketsFromGroups(groups)).toEqual([
+ { timeBucket: "2026-03-01", count: 2 },
+ { timeBucket: "2026-02-01", count: 1 },
+ { timeBucket: "undated", count: 1 },
+ ]);
+ });
+
+ it("uses a safe square ratio when dimensions are unusable", () => {
+ expect(mediaAspectRatio(1600, 800)).toBe(2);
+ expect(mediaAspectRatio(null, 800)).toBe(1);
+ expect(mediaAspectRatio(100, 0)).toBe(1);
+ });
+});
+
+describe("media timeline scroll mapping", () => {
+ const buckets = [
+ { timeBucket: "2026-03-01", count: 5 },
+ { timeBucket: "2026-02-01", count: 2 },
+ ];
+ const layout = buildScrubberLayout(buckets, {
+ columnsPerRow: 5,
+ gap: 0,
+ headerHeight: 0,
+ targetRowHeight: 100,
+ });
+ const measurements = [
+ { timeBucket: "2026-03-01", top: 0, height: 300 },
+ { timeBucket: "2026-02-01", top: 300, height: 100 },
+ ];
+
+ it("keeps within-month progress when mapping real scroll to the scrubber", () => {
+ expect(actualOffsetToScrubberOffset(measurements, layout, 150)).toBe(50);
+ expect(actualOffsetToScrubberOffset(measurements, layout, 350)).toBe(150);
+ });
+
+ it("maps scrubber positions back to real rendered sections", () => {
+ expect(scrubberOffsetToActualOffset(measurements, layout, 50)).toBe(150);
+ expect(scrubberOffsetToActualOffset(measurements, layout, 150)).toBe(350);
+ });
+});
diff --git a/frontend/src/__tests__/private-map-style.test.ts b/frontend/src/__tests__/private-map-style.test.ts
new file mode 100644
index 00000000..8f782a07
--- /dev/null
+++ b/frontend/src/__tests__/private-map-style.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildOfflineMapStyle,
+ calculateMapBounds,
+ mapMarkersToFeatureCollection,
+} from "@/components/private-map";
+import type { MapMarker } from "@/lib/api";
+
+const MARKER: MapMarker = {
+ id: 42,
+ lat: 22.5726,
+ lon: 88.3639,
+ filename: "local.jpg",
+ created_at: "2026-07-12T00:00:00Z",
+ thumbnail_url: "/api/image/42/thumbnail",
+ ratio: 1.5,
+ liked: false,
+};
+
+describe("offline private map data", () => {
+ it("builds clustered GeoJSON points in longitude-latitude order", () => {
+ expect(mapMarkersToFeatureCollection([MARKER])).toEqual({
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [88.3639, 22.5726] },
+ properties: { id: 42 },
+ },
+ ],
+ });
+ });
+
+ it("uses only bundled/local sources and enables client-side clustering", () => {
+ const style = buildOfflineMapStyle([MARKER], true);
+ const serialized = JSON.stringify(style);
+
+ expect(serialized).toContain("/maps/ne_110m_land.geojson");
+ expect(serialized).not.toMatch(/https?:\/\//);
+ expect(serialized).not.toContain('"glyphs"');
+ expect(serialized).not.toContain('"sprite"');
+ expect(serialized).toContain('"cluster":true');
+ });
+
+ it("fits nearby dateline photos without zooming out across the world", () => {
+ const west = { ...MARKER, id: 1, lon: 179.5 };
+ const east = { ...MARKER, id: 2, lon: -179.5 };
+
+ const bounds = calculateMapBounds([west, east]);
+
+ expect(bounds).not.toBeNull();
+ expect((bounds?.[2] ?? 0) - (bounds?.[0] ?? 0)).toBeCloseTo(1);
+ });
+});
diff --git a/frontend/src/__tests__/public-shared-timeline.test.tsx b/frontend/src/__tests__/public-shared-timeline.test.tsx
new file mode 100644
index 00000000..c156e534
--- /dev/null
+++ b/frontend/src/__tests__/public-shared-timeline.test.tsx
@@ -0,0 +1,146 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import PublicSharedAlbumPage from "../app/public/shared/[key]/page";
+
+const api = vi.hoisted(() => ({
+ getPublicSharedAlbum: vi.fn(),
+}));
+
+const preloadedUrls: string[] = [];
+
+class FakeResizeObserver {
+ constructor(private callback: ResizeObserverCallback) {}
+
+ observe(target: Element) {
+ this.callback(
+ [
+ {
+ target,
+ contentRect: { width: 1000, height: 0 } as DOMRectReadOnly,
+ } as ResizeObserverEntry,
+ ],
+ this as unknown as ResizeObserver,
+ );
+ }
+
+ unobserve() {}
+ disconnect() {}
+}
+
+vi.mock("@/lib/api", () => api);
+vi.mock("next/navigation", () => ({
+ useParams: () => ({ key: "safe-share" }),
+}));
+
+function renderPage() {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ api.getPublicSharedAlbum.mockReset();
+ preloadedUrls.length = 0;
+ vi.stubGlobal("ResizeObserver", FakeResizeObserver);
+ vi.stubGlobal("innerHeight", 5000);
+ vi.stubGlobal(
+ "Image",
+ class {
+ onload: (() => void) | null = null;
+ set src(value: string) {
+ preloadedUrls.push(value);
+ }
+ },
+ );
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe("PublicSharedAlbumPage timeline", () => {
+ it("opens the viewer using only share-scoped URLs", async () => {
+ api.getPublicSharedAlbum.mockResolvedValue({
+ album: { id: 1, name: "Shared", description: null },
+ allow_download: true,
+ show_exif: false,
+ total: 1,
+ items: [
+ {
+ id: 9,
+ filename: "safe.jpg",
+ width: 1200,
+ height: 800,
+ created_at: "2026-03-01T00:00:00Z",
+ thumbnail_url: "/api/public/shared/safe-share/asset/9/thumbnail",
+ url: "/api/public/shared/safe-share/asset/9/original",
+ },
+ ],
+ });
+
+ renderPage();
+ fireEvent.click(await screen.findByTestId("open-shared-asset-9"));
+
+ expect(screen.getByTestId("viewer-image")).toHaveAttribute(
+ "src",
+ "http://localhost:8000/api/public/shared/safe-share/asset/9/thumbnail",
+ );
+ await waitFor(() =>
+ expect(preloadedUrls).toContain(
+ "http://localhost:8000/api/public/shared/safe-share/asset/9/original",
+ ),
+ );
+ expect(preloadedUrls.some((url) => url.includes("/api/image/"))).toBe(
+ false,
+ );
+ expect(api.getPublicSharedAlbum).toHaveBeenCalledWith({
+ key: "safe-share",
+ password: undefined,
+ });
+ });
+
+ it("uses the share thumbnail as the view-only original fallback", async () => {
+ api.getPublicSharedAlbum.mockResolvedValue({
+ album: { id: 1, name: "View only", description: null },
+ allow_download: false,
+ show_exif: false,
+ total: 1,
+ items: [
+ {
+ id: 10,
+ filename: "view-only.jpg",
+ width: 800,
+ height: 800,
+ created_at: null,
+ thumbnail_url: "/api/public/shared/safe-share/asset/10/thumbnail",
+ url: null,
+ },
+ ],
+ });
+
+ renderPage();
+ fireEvent.click(await screen.findByTestId("open-shared-asset-10"));
+
+ await waitFor(() => expect(preloadedUrls.length).toBeGreaterThan(0));
+ expect(
+ preloadedUrls.every(
+ (url) =>
+ url ===
+ "http://localhost:8000/api/public/shared/safe-share/asset/10/thumbnail",
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/frontend/src/__tests__/root-page.test.tsx b/frontend/src/__tests__/root-page.test.tsx
new file mode 100644
index 00000000..6cea6ced
--- /dev/null
+++ b/frontend/src/__tests__/root-page.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it, vi } from "vitest";
+
+const redirect = vi.hoisted(() => vi.fn());
+
+vi.mock("next/navigation", () => ({ redirect }));
+
+import HomePage from "@/app/page";
+
+describe("root page", () => {
+ it("redirects directly to the photo timeline", () => {
+ HomePage();
+ expect(redirect).toHaveBeenCalledWith("/timeline");
+ });
+});
diff --git a/frontend/src/__tests__/settings-page.test.tsx b/frontend/src/__tests__/settings-page.test.tsx
index 22b52579..40bc87c7 100644
--- a/frontend/src/__tests__/settings-page.test.tsx
+++ b/frontend/src/__tests__/settings-page.test.tsx
@@ -10,21 +10,30 @@
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import SettingsPage from "@/app/settings/page";
import type { AppSettings, HardwareReport } from "@/lib/api";
-const { getSettings, updateSettings, getHardwareReport } = vi.hoisted(() => ({
- getSettings: vi.fn(),
- updateSettings: vi.fn(),
- getHardwareReport: vi.fn(),
-}));
+const { getSettings, updateSettings, getHardwareReport, getRuntimeConfig } =
+ vi.hoisted(() => ({
+ getSettings: vi.fn(),
+ updateSettings: vi.fn(),
+ getHardwareReport: vi.fn(),
+ getRuntimeConfig: vi.fn(),
+ }));
vi.mock("@/lib/api", () => ({
getSettings,
updateSettings,
getHardwareReport,
+ getRuntimeConfig,
}));
const REPORT: HardwareReport = {
@@ -62,8 +71,29 @@ afterEach(() => {
});
describe("SettingsPage", () => {
+ const settings = (overrides: Partial = {}): AppSettings => ({
+ accel_mode: "auto",
+ ai_enabled: true,
+ map_enabled: false,
+ ml_mode: "full",
+ supported_ml_modes: ["disabled", "mock", "full"],
+ ...overrides,
+ });
+
+ const prepareRuntime = () => {
+ getRuntimeConfig.mockResolvedValue({
+ build_profile: "cpu",
+ applied_mode: "full",
+ ai_enabled: true,
+ restart_required: false,
+ unavailable_reason: null,
+ worker: { health: { state: "healthy", age_seconds: 1 }, applied: null },
+ });
+ };
+
it("loads the persisted accel mode and selects it", async () => {
- getSettings.mockResolvedValue({ accel_mode: "cpu" } satisfies AppSettings);
+ prepareRuntime();
+ getSettings.mockResolvedValue(settings({ accel_mode: "cpu" }));
getHardwareReport.mockResolvedValue(REPORT);
renderPage();
@@ -76,11 +106,10 @@ describe("SettingsPage", () => {
});
it("persists a changed mode via updateSettings", async () => {
- getSettings.mockResolvedValue({ accel_mode: "auto" } satisfies AppSettings);
+ prepareRuntime();
+ getSettings.mockResolvedValue(settings());
getHardwareReport.mockResolvedValue(REPORT);
- updateSettings.mockResolvedValue({
- accel_mode: "gpu",
- } satisfies AppSettings);
+ updateSettings.mockResolvedValue(settings({ accel_mode: "gpu" }));
renderPage();
await waitFor(() =>
@@ -97,7 +126,8 @@ describe("SettingsPage", () => {
});
it("shows a save error when the update fails", async () => {
- getSettings.mockResolvedValue({ accel_mode: "auto" } satisfies AppSettings);
+ prepareRuntime();
+ getSettings.mockResolvedValue(settings());
getHardwareReport.mockResolvedValue(REPORT);
updateSettings.mockRejectedValue(new Error("boom"));
renderPage();
@@ -114,4 +144,52 @@ describe("SettingsPage", () => {
expect(screen.getByTestId("settings-save-error")).toBeInTheDocument(),
);
});
+
+ it("requires an explicit opt-in before enabling EXIF location storage", async () => {
+ prepareRuntime();
+ getSettings.mockResolvedValue(settings());
+ getHardwareReport.mockResolvedValue(REPORT);
+ updateSettings.mockResolvedValue(settings({ map_enabled: true }));
+ renderPage();
+
+ const mapSwitch = await screen.findByRole("switch", {
+ name: /enable private photo map/i,
+ });
+ await waitFor(() => expect(mapSwitch).toBeEnabled());
+ expect(mapSwitch).toHaveAttribute("aria-checked", "false");
+ fireEvent.click(mapSwitch);
+
+ await waitFor(() =>
+ expect(updateSettings).toHaveBeenCalledWith({ map_enabled: true }),
+ );
+ });
+
+ it("controls AI jobs and reports the installed artifact", async () => {
+ prepareRuntime();
+ getSettings.mockResolvedValue(settings({ ai_enabled: true }));
+ updateSettings.mockResolvedValue(settings({ ai_enabled: false }));
+ renderPage();
+
+ expect(await screen.findByText("cpu")).toBeInTheDocument();
+ const aiSwitch = screen.getByRole("switch", {
+ name: /enable local ai processing/i,
+ });
+ fireEvent.click(aiSwitch);
+ await waitFor(() =>
+ expect(updateSettings).toHaveBeenCalledWith({ ai_enabled: false }),
+ );
+ });
+
+ it("switches directly to any AI mode installed in the artifact", async () => {
+ prepareRuntime();
+ getSettings.mockResolvedValue(settings({ ml_mode: "mock" }));
+ updateSettings.mockResolvedValue(settings({ ml_mode: "full" }));
+ renderPage();
+
+ const mode = await screen.findByLabelText(/processing mode/i);
+ fireEvent.change(mode, { target: { value: "full" } });
+ await waitFor(() =>
+ expect(updateSettings).toHaveBeenCalledWith({ ml_mode: "full" }),
+ );
+ });
});
diff --git a/frontend/src/__tests__/timeline-media-view.test.tsx b/frontend/src/__tests__/timeline-media-view.test.tsx
new file mode 100644
index 00000000..d45b170d
--- /dev/null
+++ b/frontend/src/__tests__/timeline-media-view.test.tsx
@@ -0,0 +1,115 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { TimelineMediaView } from "@/components/timeline-media-view";
+
+class FakeResizeObserver {
+ constructor(private callback: ResizeObserverCallback) {}
+
+ observe(target: Element) {
+ this.callback(
+ [
+ {
+ target,
+ contentRect: { width: 1000, height: 0 } as DOMRectReadOnly,
+ } as ResizeObserverEntry,
+ ],
+ this as unknown as ResizeObserver,
+ );
+ }
+
+ unobserve() {}
+ disconnect() {}
+}
+
+const items = [
+ {
+ id: 1,
+ filename: "march.jpg",
+ createdAt: "2026-03-20T00:00:00Z",
+ width: 1600,
+ height: 900,
+ },
+ {
+ id: 2,
+ filename: "february.jpg",
+ createdAt: "2026-02-01T00:00:00Z",
+ width: 800,
+ height: 1200,
+ },
+];
+
+beforeEach(() => {
+ vi.stubGlobal("ResizeObserver", FakeResizeObserver);
+ vi.stubGlobal("innerHeight", 5000);
+ vi.stubGlobal("scrollTo", vi.fn());
+ vi.stubGlobal(
+ "Image",
+ class {
+ onload: (() => void) | null = null;
+ set src(_value: string) {}
+ },
+ );
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+function renderTimeline(action = vi.fn()) {
+ render(
+ item.id}
+ getDate={(item) => item.createdAt}
+ getWidth={(item) => item.width}
+ getHeight={(item) => item.height}
+ getThumbnailUrl={(item) => `/thumb/${item.id}`}
+ getOriginalUrl={(item) => `/original/${item.id}`}
+ getAlt={(item) => item.filename}
+ getOpenTestId={(item) => `open-${item.id}`}
+ renderItemActions={(item) => (
+ action(item.id)}>
+ Act on {item.id}
+
+ )}
+ />,
+ );
+ return action;
+}
+
+describe("TimelineMediaView", () => {
+ it("renders date groups, a linked keyboard scrollbar, and justified items", () => {
+ renderTimeline();
+
+ expect(screen.getByText("March 2026")).toBeInTheDocument();
+ expect(screen.getByText("February 2026")).toBeInTheDocument();
+ expect(screen.getAllByTestId("justified-grid")).toHaveLength(2);
+
+ const scrubber = screen.getByRole("scrollbar", {
+ name: "Timeline date scrubber",
+ });
+ expect(scrubber).toHaveAttribute("tabindex", "0");
+ expect(scrubber).toHaveAttribute("aria-controls", "route-media-timeline");
+ expect(screen.getByTestId("open-1").tagName).toBe("BUTTON");
+ });
+
+ it("opens the canonical AssetViewer from a media tile", () => {
+ renderTimeline();
+ fireEvent.click(screen.getByTestId("open-1"));
+
+ expect(screen.getByTestId("asset-viewer")).toBeInTheDocument();
+ expect(screen.getByTestId("viewer-image")).toHaveAttribute(
+ "src",
+ "/thumb/1",
+ );
+ });
+
+ it("keeps route actions independent from opening the viewer", () => {
+ const action = renderTimeline();
+ fireEvent.click(screen.getByRole("button", { name: "Act on 1" }));
+
+ expect(action).toHaveBeenCalledWith(1);
+ expect(screen.queryByTestId("asset-viewer")).toBeNull();
+ });
+});
diff --git a/frontend/src/__tests__/vault-gallery.test.tsx b/frontend/src/__tests__/vault-gallery.test.tsx
new file mode 100644
index 00000000..24e4aede
--- /dev/null
+++ b/frontend/src/__tests__/vault-gallery.test.tsx
@@ -0,0 +1,161 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { VaultGallery } from "@/components/vault/VaultGallery";
+import { vaultStore } from "@/store/vaultStore";
+
+const vaultClient = vi.hoisted(() => ({
+ fetchVaultOriginal: vi.fn(),
+ fetchVaultThumbnail: vi.fn(),
+ listVaultItems: vi.fn(),
+ lockVaultSession: vi.fn(),
+ restoreVaultItem: vi.fn(),
+}));
+
+vi.mock("@/components/vault/vault-client", () => ({
+ ...vaultClient,
+ isExpiredVaultSession: () => false,
+}));
+
+class FakeResizeObserver {
+ constructor(private callback: ResizeObserverCallback) {}
+
+ observe(target: Element) {
+ this.callback(
+ [
+ {
+ target,
+ contentRect: { width: 1000, height: 0 } as DOMRectReadOnly,
+ } as ResizeObserverEntry,
+ ],
+ this as unknown as ResizeObserver,
+ );
+ }
+
+ unobserve() {}
+ disconnect() {}
+}
+
+const item = {
+ id: 7,
+ filename: "private-summer.png",
+ content_type: "image/png",
+ width: 1600,
+ height: 900,
+ created_at: "2026-07-02T00:00:00Z",
+ hidden_at: "2026-07-10T00:00:00Z",
+};
+
+function renderVault() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ vi.stubGlobal("ResizeObserver", FakeResizeObserver);
+ vi.stubGlobal("innerHeight", 5000);
+ vi.stubGlobal("scrollTo", vi.fn());
+ vi.stubGlobal(
+ "Image",
+ class {
+ onload: (() => void) | null = null;
+ set src(_value: string) {}
+ },
+ );
+
+ let objectUrlSequence = 0;
+ vi.stubGlobal("URL", {
+ ...URL,
+ createObjectURL: vi.fn(() => `blob:vault-${++objectUrlSequence}`),
+ revokeObjectURL: vi.fn(),
+ });
+
+ vaultStore.getState().unlock("vault-session-token");
+ vaultClient.listVaultItems.mockResolvedValue([item]);
+ vaultClient.fetchVaultThumbnail.mockResolvedValue(
+ new Blob(["thumbnail"], { type: "image/webp" }),
+ );
+ vaultClient.fetchVaultOriginal.mockResolvedValue(
+ new Blob(["original"], { type: "image/png" }),
+ );
+ vaultClient.lockVaultSession.mockResolvedValue(undefined);
+ vaultClient.restoreVaultItem.mockResolvedValue(undefined);
+});
+
+afterEach(() => {
+ cleanup();
+ vaultStore.getState().lock();
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe("VaultGallery", () => {
+ it("uses the timeline and fetches no original until its viewer opens", async () => {
+ renderVault();
+
+ expect(await screen.findByText("July 2026")).toBeInTheDocument();
+ expect(screen.getByTestId("timeline-media-view")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(vaultClient.fetchVaultThumbnail).toHaveBeenCalledWith(
+ item.id,
+ "vault-session-token",
+ );
+ });
+ expect(vaultClient.fetchVaultOriginal).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByTestId(`open-vault-item-${item.id}`));
+
+ await waitFor(() => {
+ expect(vaultClient.fetchVaultOriginal).toHaveBeenCalledWith(
+ item.id,
+ "vault-session-token",
+ );
+ });
+ expect(screen.getByTestId("asset-viewer")).toBeInTheDocument();
+ });
+
+ it("restores a vault item through the authenticated endpoint", async () => {
+ renderVault();
+
+ const restore = await screen.findByRole("button", {
+ name: `Restore ${item.filename}`,
+ });
+ fireEvent.click(restore);
+
+ await waitFor(() => {
+ expect(vaultClient.restoreVaultItem).toHaveBeenCalledWith(
+ item.id,
+ "vault-session-token",
+ );
+ });
+ });
+
+ it("invalidates the server session and clears memory when locked", async () => {
+ renderVault();
+
+ fireEvent.click(await screen.findByRole("button", { name: "Lock Vault" }));
+
+ expect(vaultStore.getState().sessionToken).toBeNull();
+ await waitFor(() => {
+ expect(vaultClient.lockVaultSession).toHaveBeenCalledWith(
+ "vault-session-token",
+ );
+ });
+ expect(screen.getByRole("button", { name: "Unlock Vault" })).toBeVisible();
+ });
+});
diff --git a/frontend/src/__tests__/vault-store.test.ts b/frontend/src/__tests__/vault-store.test.ts
new file mode 100644
index 00000000..1b5c5e4e
--- /dev/null
+++ b/frontend/src/__tests__/vault-store.test.ts
@@ -0,0 +1,32 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { vaultStore } from "@/store/vaultStore";
+
+describe("vaultStore", () => {
+ beforeEach(() => {
+ vaultStore.getState().lock();
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+
+ it("keeps the vault session token in memory only", () => {
+ vaultStore.getState().unlock("memory-only-token");
+
+ expect(vaultStore.getState()).toMatchObject({
+ isUnlocked: true,
+ sessionToken: "memory-only-token",
+ });
+ expect(localStorage.length).toBe(0);
+ expect(sessionStorage.length).toBe(0);
+ expect(document.cookie).not.toContain("memory-only-token");
+ });
+
+ it("drops the session token when locked", () => {
+ vaultStore.getState().unlock("temporary-token");
+ vaultStore.getState().lock();
+
+ expect(vaultStore.getState()).toMatchObject({
+ isUnlocked: false,
+ sessionToken: null,
+ });
+ });
+});
diff --git a/frontend/src/app/account/page.tsx b/frontend/src/app/account/page.tsx
new file mode 100644
index 00000000..becfde97
--- /dev/null
+++ b/frontend/src/app/account/page.tsx
@@ -0,0 +1,296 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { KeyRound, LogOut, Shield, UserRound } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { type FormEvent, useEffect, useState } from "react";
+import {
+ changeAccountPassword,
+ extractErrorMessage,
+ getAccountSessions,
+ getCurrentAccount,
+ logoutAccount,
+ revokeAccountSession,
+ updateAccountProfile,
+} from "@/lib/api";
+
+export default function AccountPage() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const account = useQuery({
+ queryKey: ["account"],
+ queryFn: getCurrentAccount,
+ retry: false,
+ });
+ const [username, setUsername] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+
+ useEffect(() => {
+ if (account.data?.user) {
+ setUsername(account.data.user.username);
+ setDisplayName(account.data.user.display_name ?? "");
+ }
+ }, [account.data?.user]);
+
+ const sessions = useQuery({
+ queryKey: ["account-sessions"],
+ queryFn: getAccountSessions,
+ enabled: account.data?.mode === "shared",
+ });
+ const profile = useMutation({
+ mutationFn: updateAccountProfile,
+ onSuccess: ({ user }) =>
+ queryClient.setQueryData(["account"], {
+ mode: "shared",
+ user,
+ }),
+ });
+ const password = useMutation({
+ mutationFn: changeAccountPassword,
+ onSuccess: async () => {
+ setCurrentPassword("");
+ setNewPassword("");
+ await queryClient.invalidateQueries({ queryKey: ["account-sessions"] });
+ },
+ });
+ const revoke = useMutation({
+ mutationFn: revokeAccountSession,
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: ["account-sessions"] });
+ },
+ });
+ const logout = useMutation({
+ mutationFn: logoutAccount,
+ onSuccess: () => {
+ queryClient.clear();
+ router.replace("/auth/login");
+ },
+ });
+
+ if (account.isLoading) {
+ return (
+
+ Loading account…
+
+ );
+ }
+
+ if (account.isError) {
+ return (
+
+ Sign in required
+
+ This shared instance needs an authenticated account.
+
+
+ Go to sign in
+
+
+ );
+ }
+
+ if (account.data?.mode === "local" || !account.data?.user) {
+ return (
+
+
+
+
Private local mode
+
+ This installation has no accounts. It stays single-user and local
+ until you explicitly create an administrator.
+
+
+ Enable accounts
+
+
+
+ );
+ }
+
+ const user = account.data.user;
+ return (
+
+
+
+
+ {user.role}
+
+
Account settings
+
+ logout.mutate()}
+ className="frost-button px-4 py-2 text-sm font-medium"
+ >
+ Sign out
+
+
+
+
+
+
+
+
+
+ Active sessions
+
+ {sessions.data?.map((session) => (
+
+
+
+ {session.current ? "This browser" : "Signed-in session"}
+
+
+ Expires {new Date(session.expires_at).toLocaleString()}
+
+
+
revoke.mutate(session.id)}
+ className="frost-button px-3 py-2 text-xs font-medium"
+ >
+ Revoke
+
+
+ ))}
+ {sessions.data?.length === 0 && (
+
+ No active sessions.
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/albums/[id]/page.tsx b/frontend/src/app/albums/[id]/page.tsx
index 84cb414b..4a9fecd6 100644
--- a/frontend/src/app/albums/[id]/page.tsx
+++ b/frontend/src/app/albums/[id]/page.tsx
@@ -15,6 +15,7 @@ import { useState } from "react";
import { toast } from "sonner";
import { AlbumShareLinks } from "@/components/album-share-links";
import { AssetViewer } from "@/components/asset-viewer";
+import { TimelineMediaView } from "@/components/timeline-media-view";
import {
deleteAlbum,
getAlbum,
@@ -25,14 +26,12 @@ import {
trashImage,
updateAlbum,
} from "@/lib/api";
-import { resolveMediaUrl } from "@/lib/media";
export default function AlbumDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const albumId = Number(params?.id);
- const [viewerIndex, setViewerIndex] = useState(null);
const [removedIds, setRemovedIds] = useState>(new Set());
const { data: album, isLoading: albumLoading } = useQuery({
@@ -173,41 +172,25 @@ export default function AlbumDetailPage() {
)}
- {!assetsLoading && items.length === 0 && (
-
- This album has no photos yet.
-
- )}
-
-
- {items.map((item, index) => (
-
- setViewerIndex(index)}
- className="block h-full w-full"
- >
- {/* biome-ignore lint/a11y/useAltText: album tile */}
-
-
-
+ {!assetsLoading && (
+
item.id}
+ getDate={(item) => item.created_at}
+ getWidth={(item) => item.width}
+ getHeight={(item) => item.height}
+ getThumbnailUrl={(item) => `/api/image/${item.id}/thumbnail`}
+ getOriginalUrl={(item) => `/api/image/${item.id}/original`}
+ getAlt={(item) => item.filename}
+ getItemTestId={(item) => `album-asset-${item.id}`}
+ getOpenTestId={(item) => `open-asset-${item.id}`}
+ empty={
+
+ This album has no photos yet.
+
+ }
+ renderItemActions={(item) => (
+ <>
-
-
- ))}
-
+ >
+ )}
+ renderViewer={({ viewerAssets, index, onIndexChange, onClose }) => (
+ favoriteMutation.mutate(id)}
+ onArchive={(id) => archiveMutation.mutate(id)}
+ onTrash={(id) => trashMutation.mutate(id)}
+ />
+ )}
+ />
+ )}
{!album && !albumLoading && (
@@ -238,23 +233,6 @@ export default function AlbumDetailPage() {
)}
-
- {viewerIndex !== null && items[viewerIndex] && (
- ({
- id: item.id,
- thumbnailUrl: `/api/image/${item.id}/thumbnail`,
- originalUrl: `/api/image/${item.id}`,
- }))}
- index={viewerIndex}
- onIndexChange={setViewerIndex}
- onClose={() => setViewerIndex(null)}
- favoriteIds={favoriteIds}
- onToggleFavorite={(id) => favoriteMutation.mutate(id)}
- onArchive={(id) => archiveMutation.mutate(id)}
- onTrash={(id) => trashMutation.mutate(id)}
- />
- )}
);
}
diff --git a/frontend/src/app/albums/page.tsx b/frontend/src/app/albums/page.tsx
index 9b650e6f..c7673394 100644
--- a/frontend/src/app/albums/page.tsx
+++ b/frontend/src/app/albums/page.tsx
@@ -104,7 +104,7 @@ export default function AlbumsPage() {
src={
resolveMediaUrl(album.cover_thumbnail_url) ?? undefined
}
- alt=""
+ alt={`Cover for ${album.name}`}
className="h-full w-full object-cover"
/>
)}
diff --git a/frontend/src/app/archive/page.tsx b/frontend/src/app/archive/page.tsx
index d1ca2be0..4ef806d2 100644
--- a/frontend/src/app/archive/page.tsx
+++ b/frontend/src/app/archive/page.tsx
@@ -9,8 +9,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArchiveRestore, Loader2 } from "lucide-react";
import { toast } from "sonner";
+import { TimelineMediaView } from "@/components/timeline-media-view";
import { getArchive, setArchive } from "@/lib/api";
-import { resolveMediaUrl } from "@/lib/media";
export default function ArchivePage() {
const queryClient = useQueryClient();
@@ -50,44 +50,40 @@ export default function ArchivePage() {
)}
- {!isLoading && !isError && items.length === 0 && (
-
- No archived photos.
-
- )}
-
-
- {items.map((item) => (
-
- {/* biome-ignore lint/performance/noImgElement: thumbnail tile, not a Next-optimized route */}
-
+ {!isLoading && !isError && (
+ item.id}
+ getDate={(item) => item.created_at}
+ getWidth={(item) => item.width}
+ getHeight={(item) => item.height}
+ getThumbnailUrl={(item) => `/api/image/${item.id}/thumbnail`}
+ getOriginalUrl={(item) => `/api/image/${item.id}/original`}
+ getAlt={(item) => item.filename}
+ getItemTestId={(item) => `archive-item-${item.id}`}
+ getOpenTestId={(item) => `open-archive-${item.id}`}
+ empty={
+
+ No archived photos.
+
+ }
+ renderItemActions={(item) => (
unarchiveMutation.mutate(item.id)}
- className="absolute bottom-1 right-1 flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100"
+ disabled={
+ unarchiveMutation.isPending &&
+ unarchiveMutation.variables === item.id
+ }
+ className="flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-xs text-white disabled:opacity-50"
>
Unarchive
-
- ))}
-
+ )}
+ />
+ )}
);
diff --git a/frontend/src/app/auth/login/page.tsx b/frontend/src/app/auth/login/page.tsx
new file mode 100644
index 00000000..83ab7f11
--- /dev/null
+++ b/frontend/src/app/auth/login/page.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ArrowRight, LockKeyhole } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { type FormEvent, useState } from "react";
+import { extractErrorMessage, getAuthStatus, loginAccount } from "@/lib/api";
+
+export default function LoginPage() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const status = useQuery({
+ queryKey: ["auth-status"],
+ queryFn: getAuthStatus,
+ });
+ const login = useMutation({
+ mutationFn: loginAccount,
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: ["account"] });
+ router.replace("/timeline");
+ },
+ });
+
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+ login.mutate({ username, password });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Find
+
+
+ Sign in
+
+
+
+
+ {status.data?.mode === "local" ? (
+
+
+ This instance is in private local mode, so no sign-in is required.
+ You can keep it local or create the first administrator account.
+
+
+ Continue locally
+
+
+ Enable accounts
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/app/auth/setup/page.tsx b/frontend/src/app/auth/setup/page.tsx
new file mode 100644
index 00000000..3f99c949
--- /dev/null
+++ b/frontend/src/app/auth/setup/page.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ShieldCheck } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { type FormEvent, useEffect, useState } from "react";
+import { extractErrorMessage, getAuthStatus, setupAccount } from "@/lib/api";
+
+export default function SetupPage() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const [username, setUsername] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [password, setPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const status = useQuery({
+ queryKey: ["auth-status"],
+ queryFn: getAuthStatus,
+ });
+
+ useEffect(() => {
+ if (status.data?.mode === "shared") {
+ router.replace("/auth/login");
+ }
+ }, [router, status.data?.mode]);
+
+ const setup = useMutation({
+ mutationFn: setupAccount,
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["account"] }),
+ queryClient.invalidateQueries({ queryKey: ["auth-status"] }),
+ ]);
+ router.replace("/timeline");
+ },
+ });
+
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+ if (password !== confirmPassword) {
+ return;
+ }
+ setup.mutate({
+ username,
+ display_name: displayName || undefined,
+ password,
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Private by default
+
+
+ Create administrator
+
+
+
+
+ Accounts enable a shared Find instance. Your images and AI processing
+ remain on this server.
+
+
+
+ Keep using local mode
+
+
+
+ );
+}
diff --git a/frontend/src/app/clusters/page.tsx b/frontend/src/app/clusters/page.tsx
index 04ff83d4..49ad3399 100644
--- a/frontend/src/app/clusters/page.tsx
+++ b/frontend/src/app/clusters/page.tsx
@@ -11,12 +11,13 @@ import {
X,
} from "lucide-react";
import Image from "next/image";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
ImagePreviewModal,
type PreviewMedia,
} from "@/components/image-preview-modal";
+import { TimelineMediaView } from "@/components/timeline-media-view";
import { VirtualizedGrid } from "@/components/virtualized-grid";
import {
type ClusterDetail,
@@ -85,6 +86,7 @@ export default function ClustersPage() {
const [clusterJobId, setClusterJobId] = useState(null);
const [filterText, setFilterText] = useState("");
const [clusterLabelDraft, setClusterLabelDraft] = useState("");
+ const clusterTimelineScrollRef = useRef(null);
const { data, isLoading, error, isFetching } = useQuery({
queryKey: ["clusters"],
queryFn: getClusters,
@@ -565,7 +567,10 @@ export default function ClustersPage() {
-
+
{selectedClusterQuery.isLoading && (
@@ -643,63 +648,32 @@ export default function ClustersPage() {
)}
-
member.id}
- renderItem={(member) => {
- const imageSrc = resolveMediaUrl(
+ scrollContainerRef={clusterTimelineScrollRef}
+ getId={(member) => member.id}
+ getDate={() => null}
+ getThumbnailUrl={(member) =>
+ resolveMediaUrl(
member.thumbnail_url ?? member.url,
null,
member.id,
!member.thumbnail_url,
- );
-
- return (
-
- setPreviewMedia({
- id: member.id,
- filename: member.filename,
- url: member.url,
- caption: member.caption,
- })
- }
- className="frost-panel card-hover overflow-hidden rounded-3xl text-left"
- aria-label={`Preview ${member.filename}`}
- >
-
- {imageSrc ? (
-
- ) : (
-
-
-
- )}
-
-
-
- {member.filename}
-
- {member.caption && (
-
- {member.caption}
-
- )}
-
-
- );
- }}
+ )
+ }
+ getOriginalUrl={(member) =>
+ `/api/image/${member.id}/original`
+ }
+ getAlt={(member) => member.filename}
+ getOpenLabel={(member) => `Preview ${member.filename}`}
+ onOpenItem={(member) =>
+ setPreviewMedia({
+ id: member.id,
+ filename: member.filename,
+ url: member.url,
+ caption: member.caption,
+ })
+ }
/>
)}
diff --git a/frontend/src/app/gallery/page.tsx b/frontend/src/app/gallery/page.tsx
index 6000fe40..4f09ec31 100644
--- a/frontend/src/app/gallery/page.tsx
+++ b/frontend/src/app/gallery/page.tsx
@@ -12,7 +12,6 @@ import {
Archive,
Check,
Download,
- Eye,
FolderPlus,
Heart,
ImageOff,
@@ -21,7 +20,6 @@ import {
Trash2,
X,
} from "lucide-react";
-import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
@@ -33,7 +31,7 @@ import {
type PreviewMedia,
} from "@/components/image-preview-modal";
import { StatusIndicator } from "@/components/status-indicator";
-import { VirtualizedGrid } from "@/components/virtualized-grid";
+import { TimelineMediaView } from "@/components/timeline-media-view";
import {
api,
type DateRangePreset,
@@ -240,11 +238,6 @@ const getStatusParamFromFilter = (filter: GalleryFilter): string | null => {
return filter === "indexed" ? "completed" : filter;
};
-type GalleryThumbnailProps = {
- src: string;
- alt: string;
-};
-
type GallerySkeletonGridProps = {
count: number;
label?: string;
@@ -254,60 +247,6 @@ function buildSkeletonKeys(prefix: string, count: number) {
return Array.from({ length: count }, (_, index) => `${prefix}-${index + 1}`);
}
-/**
- * Keeps the thumbnail area stable when no preview URL exists or an image fails to load.
- */
-function GalleryImageFallback() {
- return (
-
-
- No preview
-
- );
-}
-
-/**
- * Shows a lightweight, theme-aware skeleton while each thumbnail image loads.
- */
-function GalleryThumbnail({ src, alt }: GalleryThumbnailProps) {
- const [isLoaded, setIsLoaded] = useState(false);
- const [hasError, setHasError] = useState(false);
-
- if (hasError) {
- return
;
- }
-
- return (
- <>
- {!isLoaded && (
-
- )}
-
setIsLoaded(true)}
- onError={() => {
- setIsLoaded(true);
- setHasError(true);
- }}
- />
- >
- );
-}
-
/**
* Matches the real gallery card dimensions so content does not jump when data arrives.
*/
@@ -1319,37 +1258,38 @@ function GalleryPageContent() {
)}
- item.id}
- renderItem={(item) => {
- const imageSrc = resolveMediaUrl(
+ order={sortOrder}
+ getId={(item) => item.id}
+ getDate={(item) => item.created_at}
+ getWidth={(item) => item.width}
+ getHeight={(item) => item.height}
+ getThumbnailUrl={(item) =>
+ resolveMediaUrl(
item.thumbnail_url ?? item.url,
item.minio_key,
item.id,
!item.thumbnail_url,
- );
- const originalUrl = resolveMediaUrl(item.url, item.minio_key);
- const downloadUrl = originalUrl ?? item.url ?? "";
+ )
+ }
+ getOriginalUrl={(item) => `/api/image/${item.id}/original`}
+ getAlt={(item) => item.filename}
+ getOpenLabel={(item) => `View ${item.filename}`}
+ onOpenItem={(item) => {
+ setQuerySelectedItem(null);
+ setSelectedMediaId(item.id);
+ }}
+ renderItemActions={(item) => {
const isSelected = selectedIds.has(item.id);
-
return (
-
+
+
handleToggleSelection(item.id)}
- className={`absolute left-3 top-3 z-10 grid h-8 w-8 place-items-center rounded-full border backdrop-blur-md transition ${
- isSelected
- ? "border-[color:var(--blue)] bg-[color:var(--blue)] text-white"
- : "border-white/30 bg-black/45 text-white hover:bg-black/65"
+ className={`icon-button h-8 w-8 ${
+ isSelected ? "bg-[color:var(--blue)] text-white" : ""
}`}
aria-label={
isSelected
@@ -1358,146 +1298,70 @@ function GalleryPageContent() {
}
aria-pressed={isSelected}
>
- {isSelected ? (
-
- ) : (
-
- )}
+
{
- setQuerySelectedItem(null);
- setSelectedMediaId(item.id);
- }}
- aria-label={`View ${item.filename}`}
+ onClick={() => handleToggleLike(item.id)}
+ disabled={likeMutation.isPending}
+ className="icon-button h-8 w-8"
+ aria-label={item.liked ? "Unlike image" : "Like image"}
>
- {imageSrc ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
- {item.filename}
-
-
-
handleToggleLike(item.id)}
- disabled={likeMutation.isPending}
- className={`icon-button h-8 w-8 ${
- item.liked
- ? "border-[var(--red)] bg-[var(--red-soft)] text-[color:var(--red)]"
- : "text-[color:var(--silver)]"
- } ${
- likeMutation.isPending
- ? "cursor-not-allowed opacity-70"
- : ""
- }`}
- aria-label={
- item.liked ? "Unlike image" : "Like image"
- }
- >
-
-
- {downloadUrl && (
-
-
-
- )}
- {(item.status === "failed" ||
- (item.status === "indexed" && !item.caption)) && (
-
reprocessMutation.mutate(item.id)}
- disabled={
- reprocessMutation.isPending &&
- reprocessMutation.variables === item.id
- }
- className={`icon-button h-8 w-8 text-[color:var(--silver)] ${
- reprocessMutation.isPending &&
- reprocessMutation.variables === item.id
- ? "cursor-not-allowed opacity-70"
- : ""
- }`}
- aria-label="Retry analysis"
- >
-
-
- )}
- {isVaultUnlocked && vaultSessionToken && (
-
moveToVaultMutation.mutate(item.id)}
- disabled={
- moveToVaultMutation.isPending &&
- moveToVaultMutation.variables === item.id
- }
- className={`icon-button h-8 w-8 text-[color:var(--silver)] ${
- moveToVaultMutation.isPending &&
- moveToVaultMutation.variables === item.id
- ? "cursor-not-allowed opacity-70"
- : ""
- }`}
- aria-label="Move to Vault"
- title="Move to Vault"
- >
-
-
- )}
-
- handleDeleteRequest(item.id, item.filename)
- }
- disabled={deleteMutation.isPending}
- className={`icon-button h-8 w-8 text-[color:var(--silver)] ${
- deleteMutation.isPending
- ? "cursor-not-allowed opacity-70"
- : ""
- }`}
- aria-label="Delete image"
- >
-
-
-
-
-
+
+
+
+ {(item.status === "failed" ||
+ (item.status === "indexed" && !item.caption)) && (
+
reprocessMutation.mutate(item.id)}
+ disabled={
+ reprocessMutation.isPending &&
+ reprocessMutation.variables === item.id
+ }
+ className="icon-button h-8 w-8"
+ aria-label="Retry analysis"
+ >
+
+
+ )}
+ {isVaultUnlocked && vaultSessionToken && (
+
moveToVaultMutation.mutate(item.id)}
+ disabled={
+ moveToVaultMutation.isPending &&
+ moveToVaultMutation.variables === item.id
+ }
+ className="icon-button h-8 w-8"
+ aria-label="Move to Vault"
+ >
+
+
+ )}
+
+ handleDeleteRequest(item.id, item.filename)
+ }
+ disabled={deleteMutation.isPending}
+ className="icon-button h-8 w-8"
+ aria-label="Delete image"
+ >
+
+
+
);
}}
/>
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index a3a23be4..fe35b0f8 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -44,7 +44,8 @@
--status-pending-text: #ffe08a;
--status-failed-border: rgba(255, 32, 71, 0.3);
--status-failed-text: #ff9bab;
- --nav-height: 72px;
+ --nav-height: 64px;
+ --sidebar-width: 256px;
}
html.light,
html[data-theme="light"] {
@@ -95,7 +96,8 @@
--status-failed-border: rgba(239, 68, 68, 0.35);
--status-failed-text: #991b1b;
- --nav-height: 72px;
+ --nav-height: 64px;
+ --sidebar-width: 256px;
}
html.dark,
@@ -147,7 +149,8 @@
--status-failed-border: rgba(255, 32, 71, 0.3);
--status-failed-text: #ff9bab;
- --nav-height: 72px;
+ --nav-height: 64px;
+ --sidebar-width: 256px;
}
* {
@@ -182,6 +185,9 @@
"Segoe UI", sans-serif;
overflow-y: auto;
overflow-x: hidden;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
}
button,
@@ -190,6 +196,16 @@
-webkit-tap-highlight-color: transparent;
}
+ button:not(:disabled),
+ [role="button"]:not([aria-disabled="true"]) {
+ cursor: pointer;
+ }
+
+ :focus-visible {
+ outline: 2px solid var(--blue);
+ outline-offset: 3px;
+ }
+
::selection {
background: rgba(59, 158, 255, 0.34);
color: #ffffff;
@@ -240,46 +256,37 @@
@layer components {
.page-shell {
min-height: calc(100dvh - var(--nav-height));
+ padding-bottom: clamp(3rem, 7vw, 6rem);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 18rem),
var(--void);
}
- .home-shell {
- height: calc(100dvh - var(--nav-height));
- overflow: hidden;
- background:
- linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 18rem),
- var(--void);
- }
-
- body:has(.home-shell) {
- overflow: hidden;
+ .page-surface {
+ min-height: calc(100dvh - var(--nav-height));
+ width: 100%;
+ padding-inline: clamp(1rem, 3.5vw, 3rem);
}
- .container-shell {
- width: min(1180px, calc(100% - 32px));
- margin: 0 auto;
+ .app-shell-scrollbar {
+ scrollbar-gutter: stable;
}
- .hero-viewport {
- height: 100%;
+ .safe-bottom {
+ padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
}
- .display-heading {
- font-family: Georgia, "Times New Roman", serif;
- font-variant-ligatures: common-ligatures;
- font-feature-settings: "ss01", "ss04", "ss11";
- color: var(--near-white);
- line-height: 1;
- letter-spacing: 0;
+ .container-shell {
+ width: min(1280px, calc(100% - clamp(2rem, 7vw, 6rem)));
+ margin: 0 auto;
}
.section-heading {
color: var(--near-white);
font-feature-settings: "ss01", "ss04", "ss11";
line-height: 1.08;
- letter-spacing: 0;
+ letter-spacing: -0.035em;
+ text-wrap: balance;
}
.muted-copy {
@@ -450,10 +457,6 @@
animation: fade-up 620ms 90ms ease both;
}
- .soft-pulse {
- animation: soft-pulse 2.4s ease-in-out infinite;
- }
-
.scan-line {
position: relative;
overflow: hidden;
@@ -475,14 +478,6 @@
}
}
-@media (max-width: 600px) {
- @layer components {
- .hero-viewport {
- height: 100%;
- }
- }
-}
-
@keyframes fade-up {
from {
opacity: 0;
@@ -494,16 +489,6 @@
}
}
-@keyframes soft-pulse {
- 0%,
- 100% {
- opacity: 0.72;
- }
- 50% {
- opacity: 1;
- }
-}
-
@keyframes scan {
0%,
45% {
@@ -513,8 +498,13 @@
transform: translateX(120%);
}
}
-@media (max-width: 768px) {
- .cursor-glow {
- display: none;
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto;
+ animation-duration: 0.01ms;
+ animation-iteration-count: 1;
+ transition-duration: 0.01ms;
}
}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 2bc33a1d..b59ea372 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -1,9 +1,6 @@
import type { Metadata } from "next";
-import Image from "next/image";
-import Link from "next/link";
import "./globals.css";
-import CursorGlow from "@/components/CursorGlow";
-import NavBar from "@/components/NavBar";
+import { AppShell } from "@/components/app-shell";
import { Providers } from "./providers";
export const metadata: Metadata = {
@@ -22,36 +19,7 @@ export default function RootLayout({
-
-
-
-
-
-
-
-
- FIND.
-
-
-
-
-
-
-
- {children}
-
-
- © 2026 Find. AGPL-3.0 License.
-
+ {children}
diff --git a/frontend/src/app/map/page.tsx b/frontend/src/app/map/page.tsx
new file mode 100644
index 00000000..8a63bd9a
--- /dev/null
+++ b/frontend/src/app/map/page.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import {
+ Archive,
+ Heart,
+ LocateFixed,
+ MapPinned,
+ MapPinOff,
+ RefreshCw,
+ ShieldCheck,
+} from "lucide-react";
+import Link from "next/link";
+import { type ReactNode, useMemo, useState } from "react";
+import { MapTimelinePanel } from "@/components/map-timeline-panel";
+import { PrivateMap } from "@/components/private-map";
+import { getMapMarkers } from "@/lib/api";
+
+function FilterButton({
+ active,
+ label,
+ icon,
+ onClick,
+ testId,
+}: {
+ active: boolean;
+ label: string;
+ icon: ReactNode;
+ onClick: () => void;
+ testId: string;
+}) {
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
+
+export default function MapPage() {
+ const [onlyFavorites, setOnlyFavorites] = useState(false);
+ const [includeArchived, setIncludeArchived] = useState(false);
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [fitRequest, setFitRequest] = useState(0);
+
+ const markersQuery = useQuery({
+ queryKey: ["map-markers", { onlyFavorites, includeArchived }],
+ queryFn: () =>
+ getMapMarkers({
+ liked: onlyFavorites ? true : undefined,
+ includeArchived,
+ }),
+ retry: false,
+ refetchOnMount: "always",
+ placeholderData: (previousData) => previousData,
+ });
+
+ const markers = markersQuery.data?.markers ?? [];
+ const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
+ const selectedMarkers = useMemo(
+ () => markers.filter((marker) => selectedIdSet.has(marker.id)),
+ [markers, selectedIdSet],
+ );
+
+ return (
+
+
+
+ {markersQuery.isPending ? (
+
+
+
+ Loading private map…
+
+
+ ) : markersQuery.isError ? (
+
+
+
+
Map could not load
+
+ Your photos are unchanged. Retry the local API request.
+
+
markersQuery.refetch()}
+ className="mt-5 rounded-xl bg-[color:var(--near-white)] px-4 py-2 text-sm font-semibold text-[color:var(--void)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--blue)]"
+ >
+ Retry
+
+
+
+ ) : !markersQuery.data.enabled ? (
+
+
+
+
+
+
+
+ Your private map is off
+
+
+ Location extraction is opt-in. Enable it in Settings, then upload
+ new photos or reprocess existing ones to read local EXIF GPS.
+ Online map tiles and reverse geocoding remain disabled.
+
+
+ Open map privacy settings
+
+
+
+ ) : (
+ <>
+ 0
+ ? "xl:grid-cols-[minmax(0,2fr)_minmax(18rem,0.8fr)]"
+ : "grid-cols-1"
+ }`}
+ >
+
+ {selectedMarkers.length > 0 && (
+
setSelectedIds([])}
+ />
+ )}
+
+
+ {markers.length === 0 && (
+
+
+
+ No matching photos have GPS metadata yet. Upload a geotagged
+ photo or reprocess an existing one after enabling the map.
+
+
+ )}
+
+
+
+ {markersQuery.data.total.toLocaleString()} mapped{" "}
+ {markersQuery.data.total === 1 ? "photo" : "photos"}
+
+
+ Bundled Natural Earth land geometry · local API data only
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 2cb277c3..bea43f99 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,83 +1,5 @@
-import { ArrowRight, Image as ImageIcon, Lock, ScanSearch } from "lucide-react";
-import Link from "next/link";
+import { redirect } from "next/navigation";
export default function HomePage() {
- return (
-
-
-
-
- Your visual
- memory,
- indexed.
-
- Your visual memory,
-
- indexed.
-
-
-
-
- AI-powered image intelligence that runs entirely on your device.
- Fast, private, and beautiful.
-
-
-
-
- Start uploading
-
-
-
-
- Search library
-
-
-
-
-
-
-
-
-
- Private
-
-
- 100% local processing
-
-
-
-
-
-
-
-
- Intelligent
-
-
- Natural language search
-
-
-
-
-
-
-
-
- Organized
-
-
- Automatic clustering
-
-
-
-
-
-
- );
+ redirect("/timeline");
}
diff --git a/frontend/src/app/people/page.tsx b/frontend/src/app/people/page.tsx
index cf306de0..c0eb19f6 100644
--- a/frontend/src/app/people/page.tsx
+++ b/frontend/src/app/people/page.tsx
@@ -11,13 +11,14 @@ import {
X,
} from "lucide-react";
import Image from "next/image";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
ImagePreviewModal,
type PreviewMedia,
} from "@/components/image-preview-modal";
import { FeedbackActions } from "@/components/person-feedback-actions";
+import { TimelineMediaView } from "@/components/timeline-media-view";
import { VirtualizedGrid } from "@/components/virtualized-grid";
import {
getPeople,
@@ -189,6 +190,7 @@ export default function PeoplePage() {
const queryClient = useQueryClient();
const [selectedPersonId, setSelectedPersonId] = useState(null);
const [previewMedia, setPreviewMedia] = useState(null);
+ const personTimelineScrollRef = useRef(null);
const {
data: people,
@@ -411,7 +413,10 @@ export default function PeoplePage() {
)}
-
+
{selectedPersonQuery.isLoading && (
@@ -425,77 +430,46 @@ export default function PeoplePage() {
)}
{selectedPersonQuery.data && (
-
img.media_id}
- renderItem={(img) => {
+ scrollContainerRef={personTimelineScrollRef}
+ getId={(img) => img.media_id}
+ getDate={() => null}
+ getThumbnailUrl={(img) =>
+ resolveMediaUrl(img.thumbnail_url, null, img.media_id, true)
+ }
+ getOriginalUrl={(img) =>
+ `/api/image/${img.media_id}/original`
+ }
+ getAlt={(img) => img.filename}
+ getOpenLabel={(img) => `Preview ${img.filename}`}
+ onOpenItem={(img) =>
+ setPreviewMedia({
+ id: img.media_id,
+ filename: img.filename,
+ })
+ }
+ renderItemActions={(img) => {
const faceIds = img.faces.map((face) => face.id);
- const imageSrc = resolveMediaUrl(
- img.thumbnail_url,
- null,
- img.media_id,
- true,
- );
-
return (
- {
+ if (selectedPersonId === null) return;
+ wrongPersonMutation.mutate({
+ personId: selectedPersonId,
+ faceIds,
+ });
+ }}
+ disabled={
+ faceIds.length === 0 || wrongPersonMutation.isPending
+ }
+ className="frost-button px-3 py-2 text-xs font-medium disabled:opacity-50"
>
-
- setPreviewMedia({
- id: img.media_id,
- filename: img.filename,
- })
- }
- className="block w-full text-left"
- aria-label={`Preview ${img.filename}`}
- >
-
- {imageSrc ? (
-
- ) : null}
-
-
-
-
- {img.faces.length}{" "}
- {img.faces.length === 1 ? "face" : "faces"} detected
-
-
{
- if (selectedPersonId === null) {
- return;
- }
- wrongPersonMutation.mutate({
- personId: selectedPersonId,
- faceIds,
- });
- }}
- disabled={
- faceIds.length === 0 ||
- wrongPersonMutation.isPending
- }
- className="frost-button w-full justify-center px-3 py-2 text-xs font-medium disabled:cursor-not-allowed disabled:opacity-50"
- >
- {wrongPersonMutation.isPending
- ? "Saving..."
- : "Wrong person"}
-
-
-
+ {wrongPersonMutation.isPending
+ ? "Saving…"
+ : "Wrong person"}
+
);
}}
/>
diff --git a/frontend/src/app/public/shared/[key]/page.tsx b/frontend/src/app/public/shared/[key]/page.tsx
index 720dc377..cb179489 100644
--- a/frontend/src/app/public/shared/[key]/page.tsx
+++ b/frontend/src/app/public/shared/[key]/page.tsx
@@ -13,6 +13,7 @@ import { useQuery } from "@tanstack/react-query";
import { Loader2, Lock } from "lucide-react";
import { useParams } from "next/navigation";
import { useState } from "react";
+import { TimelineMediaView } from "@/components/timeline-media-view";
import { getPublicSharedAlbum, type PublicSharedAlbum } from "@/lib/api";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
@@ -110,33 +111,22 @@ export default function PublicSharedAlbumPage() {
)}
{data.total} photos
-
+ item.id}
+ getDate={(item) => item.created_at}
+ getWidth={(item) => item.width}
+ getHeight={(item) => item.height}
+ // Security boundary: both URLs come from the share response. The
+ // timeline adapter never synthesizes a private `/api/image` route.
+ getThumbnailUrl={(item) => withBase(item.thumbnail_url)}
+ getOriginalUrl={(item) => (item.url ? withBase(item.url) : null)}
+ getAlt={(item) => item.filename}
+ getItemTestId={(item) => `shared-asset-${item.id}`}
+ getOpenTestId={(item) => `open-shared-asset-${item.id}`}
+ empty={This album has no photos.
}
+ />
);
diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx
index 05722e5e..802771a6 100644
--- a/frontend/src/app/search/page.tsx
+++ b/frontend/src/app/search/page.tsx
@@ -7,12 +7,10 @@ import {
Loader2,
Search as SearchIcon,
} from "lucide-react";
-import Image from "next/image";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { FeedbackRating } from "@/components/feedback-rating";
import { ImagePreviewModal } from "@/components/image-preview-modal";
-import { StatusIndicator } from "@/components/status-indicator";
-import { VirtualizedGrid } from "@/components/virtualized-grid";
+import { TimelineMediaView } from "@/components/timeline-media-view";
import { type SearchResult, searchImages, submitSearchRating } from "@/lib/api";
import { MINIO_URL_REFRESH_INTERVAL_MS, resolveMediaUrl } from "@/lib/media";
@@ -26,7 +24,6 @@ const examples = [
export default function SearchPage() {
const [query, setQuery] = useState("");
const [activeQuery, setActiveQuery] = useState("");
- const [selectedMediaId, setSelectedMediaId] = useState
(null);
const [allResults, setAllResults] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [currentSkip, setCurrentSkip] = useState(0);
@@ -75,7 +72,6 @@ export default function SearchPage() {
const trimmedQuery = query.trim();
if (trimmedQuery) {
clearedRef.current = false;
- setSelectedMediaId(null);
setAllResults([]);
setHasMore(false);
setCurrentSkip(0);
@@ -108,28 +104,6 @@ export default function SearchPage() {
}
};
- const results = allResults;
- const selectedIndex = useMemo(() => {
- if (selectedMediaId === null) {
- return -1;
- }
- return results.findIndex((result) => result.media_id === selectedMediaId);
- }, [results, selectedMediaId]);
- const selectedMedia = selectedIndex >= 0 ? results[selectedIndex] : null;
-
- const goToAdjacent = useCallback(
- (direction: -1 | 1) => {
- if (selectedIndex < 0) {
- return;
- }
- const next = results[selectedIndex + direction];
- if (next) {
- setSelectedMediaId(next.media_id);
- }
- },
- [results, selectedIndex],
- );
-
return (
@@ -179,7 +153,6 @@ export default function SearchPage() {
clearedRef.current = true;
setQuery("");
searchMutation.reset();
- setSelectedMediaId(null);
setActiveQuery("");
setAllResults([]);
setHasMore(false);
@@ -200,7 +173,6 @@ export default function SearchPage() {
onClick={() => {
clearedRef.current = false;
setQuery(example);
- setSelectedMediaId(null);
setAllResults([]);
setHasMore(false);
setCurrentSkip(0);
@@ -264,85 +236,59 @@ export default function SearchPage() {
-
result.media_id}
- renderItem={(result) => {
- const imageSrc = resolveMediaUrl(
+ getId={(result) => result.media_id}
+ getDate={(result) => result.metadata.created_at}
+ getWidth={(result) => result.metadata.width}
+ getHeight={(result) => result.metadata.height}
+ getThumbnailUrl={(result) =>
+ resolveMediaUrl(
result.metadata.thumbnail_url ?? result.metadata.url,
result.metadata.minio_key,
result.media_id,
!result.metadata.thumbnail_url,
- );
-
+ )
+ }
+ getOriginalUrl={(result) =>
+ `/api/image/${result.media_id}/original`
+ }
+ getAlt={(result) => result.metadata.filename}
+ getOpenLabel={(result) => `Preview ${result.metadata.filename}`}
+ renderItemActions={(result) => (
+
+
+ {Math.round(result.similarity * 100)}%
+
+
+ submitSearchRating(result.media_id, rating)
+ }
+ />
+
+ )}
+ renderViewer={({ items, index, onIndexChange, onClose }) => {
+ const result = items[index];
+ if (!result) return null;
return (
-
- setSelectedMediaId(result.media_id)}
- className="block w-full text-left"
- aria-label={`Preview ${result.metadata.filename}`}
- >
-
- {imageSrc ? (
-
- ) : (
-
-
- No preview
-
- )}
-
-
- {Math.round(result.similarity * 100)}%
-
-
-
-
-
-
- {result.metadata.filename}
-
- {result.metadata.caption && (
-
- {result.metadata.caption}
-
- )}
-
- {typeof result.metadata.cluster_id === "number" && (
-
- Cluster {result.metadata.cluster_id}
-
- )}
-
-
-
-
-
-
- submitSearchRating(result.media_id, rating)
- }
- />
-
-
+ onIndexChange(index - 1)}
+ onNext={() => onIndexChange(index + 1)}
+ hasPrevious={index > 0}
+ hasNext={index < items.length - 1}
+ onDeleted={(mediaId) => {
+ setAllResults((current) =>
+ current.filter((item) => item.media_id !== mediaId),
+ );
+ onClose();
+ }}
+ />
);
}}
/>
@@ -369,25 +315,6 @@ export default function SearchPage() {
)}
-
- {selectedMedia && (
-
setSelectedMediaId(null)}
- onPrevious={() => goToAdjacent(-1)}
- onNext={() => goToAdjacent(1)}
- hasPrevious={selectedIndex > 0}
- hasNext={selectedIndex >= 0 && selectedIndex < results.length - 1}
- onDeleted={(mediaId) => {
- if (selectedMediaId === mediaId) {
- setSelectedMediaId(null);
- }
- }}
- />
- )}
);
}
diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx
index 8e30b340..f5f0ee8d 100644
--- a/frontend/src/app/settings/page.tsx
+++ b/frontend/src/app/settings/page.tsx
@@ -1,47 +1,182 @@
"use client";
-/**
- * Settings page. Phase 5.1 ships the hardware-acceleration section wired to a
- * real backend: it loads the persisted accel mode (`GET /api/settings`) and
- * saves changes (`PUT /api/settings`). Additional groups (library/storage, ML,
- * sharing, appearance, advanced) land as their backends do — we avoid stubbing
- * groups with no persistence behind them (YAGNI).
- */
-
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ Cpu,
+ MapPinned,
+ RefreshCw,
+ Settings2,
+ ShieldCheck,
+ Sparkles,
+} from "lucide-react";
+import { AiRuntimeSettings } from "@/components/ai-runtime-settings";
import { HardwareAccelSettings } from "@/components/hardware-accel-settings";
-import { type AccelMode, getSettings, updateSettings } from "@/lib/api";
+import { MapPrivacySettings } from "@/components/map-privacy-settings";
+import { type AppSettings, getSettings, updateSettings } from "@/lib/api";
+
+const SECTIONS = [
+ { href: "#hardware", label: "Performance", icon: Cpu },
+ { href: "#ai-runtime-heading", label: "Local AI", icon: Sparkles },
+ { href: "#private-map", label: "Privacy & map", icon: MapPinned },
+] as const;
export default function SettingsPage() {
const queryClient = useQueryClient();
-
- const { data: settings } = useQuery({
+ const settingsQuery = useQuery({
queryKey: ["settings"],
queryFn: getSettings,
});
const save = useMutation({
- mutationFn: (mode: AccelMode) => updateSettings({ accel_mode: mode }),
+ mutationFn: (patch: Partial) => updateSettings(patch),
onSuccess: (next) => {
queryClient.setQueryData(["settings"], next);
- // The hardware report depends on the persisted mode — refresh it so the
- // resolved plan + fallback notice reflect the new choice.
queryClient.invalidateQueries({ queryKey: ["hardware-report"] });
+ queryClient.invalidateQueries({ queryKey: ["runtime-config"] });
+ queryClient.invalidateQueries({ queryKey: ["map-markers"] });
},
});
return (
-
- Settings
- save.mutate(mode)}
- />
- {save.isError && (
-
- Couldn't save settings. Please try again.
-
- )}
+
+
+
+
+
+
+
+
+ {settingsQuery.isPending ? (
+
+ {[0, 1, 2].map((item) => (
+
+ ))}
+
+ ) : settingsQuery.isError ? (
+
+
+ Settings could not load
+
+
+ Your existing configuration is unchanged. Check the local API
+ and try again.
+
+ settingsQuery.refetch()}
+ className="mt-5 inline-flex h-10 items-center gap-2 rounded-xl bg-[color:var(--near-white)] px-4 text-sm font-semibold text-[color:var(--void)] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--blue)]"
+ >
+
+ Retry
+
+
+ ) : (
+
+
save.mutate({ accel_mode: mode })}
+ />
+ save.mutate({ ai_enabled: enabled })}
+ mode={settingsQuery.data.ml_mode}
+ supportedModes={settingsQuery.data.supported_ml_modes}
+ onModeChange={(ml_mode) => save.mutate({ ml_mode })}
+ />
+ save.mutate({ map_enabled: enabled })}
+ />
+
+ )}
+
+ {save.isError && (
+
+ Couldn't save settings. Your previous value remains active.
+
+ )}
+
+
+
);
}
diff --git a/frontend/src/app/timeline/page.tsx b/frontend/src/app/timeline/page.tsx
index 2ff6b1a4..9f343877 100644
--- a/frontend/src/app/timeline/page.tsx
+++ b/frontend/src/app/timeline/page.tsx
@@ -25,11 +25,22 @@ import { useTimeline } from "@/lib/use-timeline";
export default function TimelinePage() {
const queryClient = useQueryClient();
- const [likedOnly, setLikedOnly] = useState(false);
- const { buckets, assets, total, isLoadingBuckets, isError, loadBucket } =
- useTimeline({
- liked: likedOnly || undefined,
- });
+ const [likedOnly, setLikedOnly] = useState(
+ () =>
+ typeof window !== "undefined" &&
+ new URLSearchParams(window.location.search).get("liked") === "true",
+ );
+ const {
+ buckets,
+ assets,
+ total,
+ isLoadingBuckets,
+ isError,
+ loadBucket,
+ loadedBucketKeys,
+ } = useTimeline({
+ liked: likedOnly || undefined,
+ });
const [scrollOffset, setScrollOffset] = useState(0);
const [viewerIndex, setViewerIndex] = useState(null);
// Local favorite overrides for instant feedback (the per-bucket cache isn't
@@ -41,6 +52,7 @@ export default function TimelinePage() {
// per-bucket cache still holds them, so we hide them locally.
const [removedIds, setRemovedIds] = useState>(new Set());
const scrollRef = useRef(null);
+ const loadMoreRef = useRef(null);
const visibleAssets = useMemo(
() => assets.filter((a) => !removedIds.has(a.id)),
@@ -91,10 +103,57 @@ export default function TimelinePage() {
}
}, [buckets, loadBucket]);
+ const nextBucket = useMemo(() => {
+ const loaded = new Set(loadedBucketKeys);
+ return buckets.find((bucket) => !loaded.has(bucket.timeBucket));
+ }, [buckets, loadedBucketKeys]);
+
+ useEffect(() => {
+ const sentinel = loadMoreRef.current;
+ if (
+ !sentinel ||
+ !nextBucket ||
+ typeof IntersectionObserver === "undefined"
+ ) {
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ loadBucket(nextBucket.timeBucket);
+ }
+ },
+ { rootMargin: "800px 0px" },
+ );
+ observer.observe(sentinel);
+ return () => observer.disconnect();
+ }, [loadBucket, nextBucket]);
+
// When the user scrubs: load the target month's data AND scroll the grid to
// it. The scrubber works in estimated-height space and the grid in real
// layout space, so we bridge via a 0..1 fraction → window scroll position.
- const scrubberLayout = buildScrubberLayout(buckets);
+ const scrubberLayout = useMemo(() => buildScrubberLayout(buckets), [buckets]);
+
+ useEffect(() => {
+ if (typeof window === "undefined" || scrubberLayout.totalHeight <= 0) {
+ return;
+ }
+ const update = () => {
+ const documentElement = document.documentElement;
+ const scrollable = Math.max(
+ 1,
+ documentElement.scrollHeight - window.innerHeight,
+ );
+ const fraction = Math.min(1, Math.max(0, window.scrollY / scrollable));
+ const offset = fraction * scrubberLayout.totalHeight;
+ setScrollOffset(offset);
+ const segment = offsetToSegment(scrubberLayout, offset);
+ if (segment) loadBucket(segment.timeBucket);
+ };
+ update();
+ window.addEventListener("scroll", update, { passive: true });
+ return () => window.removeEventListener("scroll", update);
+ }, [loadBucket, scrubberLayout]);
const handleScrub = useCallback(
(offset: number) => {
setScrollOffset(offset);
@@ -115,21 +174,51 @@ export default function TimelinePage() {
const viewerAssets = visibleAssets.map((a) => ({
id: a.id,
thumbnailUrl: a.thumbnailUrl,
- originalUrl: `/api/image/${a.id}`,
+ originalUrl: `/api/image/${a.id}/original`,
+ alt: a.createdAt
+ ? `Photo from ${new Date(a.createdAt).toLocaleDateString()}`
+ : `Photo ${a.id}`,
}));
return (
-
-
- Timeline
- {!isLoadingBuckets && (
- {total} photos
- )}
+
+
+
+
+ Library
+
+
+ Photos
+
+ {!isLoadingBuckets && (
+
+ {total} photos
+
+ )}
+
setLikedOnly((v) => !v)}
+ onClick={() =>
+ setLikedOnly((current) => {
+ const next = !current;
+ if (typeof window !== "undefined") {
+ const url = new URL(window.location.href);
+ if (next) url.searchParams.set("liked", "true");
+ else url.searchParams.delete("liked");
+ window.history.replaceState(null, "", url);
+ }
+ return next;
+ })
+ }
+ className="frost-button px-4 py-2 text-sm font-medium"
>
{likedOnly ? "Showing favorites" : "Show favorites"}
@@ -151,7 +240,7 @@ export default function TimelinePage() {
No photos yet.
)}
-
+
{buckets.length > 0 && (
-
+
)}
diff --git a/frontend/src/app/trash/page.tsx b/frontend/src/app/trash/page.tsx
index df754309..a55290e1 100644
--- a/frontend/src/app/trash/page.tsx
+++ b/frontend/src/app/trash/page.tsx
@@ -9,8 +9,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, RotateCcw, Trash2 } from "lucide-react";
import { toast } from "sonner";
+import { TimelineMediaView } from "@/components/timeline-media-view";
import { emptyTrash, getTrash, restoreImage } from "@/lib/api";
-import { resolveMediaUrl } from "@/lib/media";
export default function TrashPage() {
const queryClient = useQueryClient();
@@ -76,44 +76,40 @@ export default function TrashPage() {
)}
- {!isLoading && !isError && items.length === 0 && (
-
- Trash is empty.
-
- )}
-
-
- {items.map((item) => (
-
- {/* biome-ignore lint/a11y/useAltText: trash tile */}
-
+ {!isLoading && !isError && (
+ item.id}
+ getDate={(item) => item.created_at}
+ getWidth={(item) => item.width}
+ getHeight={(item) => item.height}
+ getThumbnailUrl={(item) => `/api/image/${item.id}/thumbnail`}
+ getOriginalUrl={(item) => `/api/image/${item.id}/original`}
+ getAlt={(item) => item.filename}
+ getItemTestId={(item) => `trash-item-${item.id}`}
+ getOpenTestId={(item) => `open-trash-${item.id}`}
+ empty={
+
+ Trash is empty.
+
+ }
+ renderItemActions={(item) => (
restoreMutation.mutate(item.id)}
- className="absolute bottom-1 right-1 flex items-center gap-1 rounded-full bg-black/60 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100"
+ disabled={
+ restoreMutation.isPending &&
+ restoreMutation.variables === item.id
+ }
+ className="flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-xs text-white disabled:opacity-50"
>
Restore
-
- ))}
-
+ )}
+ />
+ )}
);
diff --git a/frontend/src/components/CursorGlow.tsx b/frontend/src/components/CursorGlow.tsx
deleted file mode 100644
index 8f178cba..00000000
--- a/frontend/src/components/CursorGlow.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-
-export default function CursorGlow() {
- const glowRef = useRef(null);
-
- useEffect(() => {
- let mouseX = window.innerWidth / 2;
- let mouseY = window.innerHeight / 2;
- let currentX = mouseX;
- let currentY = mouseY;
- let animationFrame: number;
-
- const handleMouseMove = (e: MouseEvent) => {
- mouseX = e.clientX;
- mouseY = e.clientY;
- };
-
- const animate = () => {
- // Smooth interpolation for premium motion
- currentX += (mouseX - currentX) * 0.08;
- currentY += (mouseY - currentY) * 0.08;
-
- if (glowRef.current) {
- // Center a 220px glow under the cursor
- glowRef.current.style.transform = `translate(${currentX - 110}px, ${
- currentY - 110
- }px)`;
- }
-
- animationFrame = requestAnimationFrame(animate);
- };
-
- window.addEventListener("mousemove", handleMouseMove);
- animationFrame = requestAnimationFrame(animate);
-
- return () => {
- window.removeEventListener("mousemove", handleMouseMove);
- cancelAnimationFrame(animationFrame);
- };
- }, []);
-
- return (
-
- );
-}
diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx
index 3bfd93d4..bd33dea7 100644
--- a/frontend/src/components/NavBar.tsx
+++ b/frontend/src/components/NavBar.tsx
@@ -1,273 +1,139 @@
"use client";
-import { Menu, Moon, Sun, X } from "lucide-react";
+import {
+ Archive,
+ Boxes,
+ Heart,
+ Images,
+ Library,
+ LockKeyhole,
+ type LucideIcon,
+ Map as MapIcon,
+ Search,
+ Settings,
+ Trash2,
+ Users,
+} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { useEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
-import { getAppConfig } from "@/lib/api";
-
-const navLinks = [
- { href: "/upload", label: "Upload" },
- { href: "/gallery", label: "Gallery" },
- { href: "/timeline", label: "Timeline" },
- { href: "/albums", label: "Albums" },
- { href: "/vault", label: "Vault" },
- { href: "/search", label: "Search" },
- { href: "/clusters", label: "Clusters" },
- { href: "/duplicates", label: "Duplicates" },
- { href: "/people", label: "People" },
- { href: "/archive", label: "Archive" },
- { href: "/trash", label: "Trash" },
- { href: "/settings", label: "Settings" },
+import { useEffect, useState } from "react";
+
+type NavItem = {
+ href: string;
+ label: string;
+ icon: LucideIcon;
+};
+
+type NavGroup = {
+ label: string;
+ items: NavItem[];
+};
+
+export const NAV_GROUPS: NavGroup[] = [
+ {
+ label: "Library",
+ items: [
+ { href: "/timeline", label: "Photos", icon: Images },
+ { href: "/search", label: "Search", icon: Search },
+ { href: "/map", label: "Map", icon: MapIcon },
+ { href: "/people", label: "People", icon: Users },
+ ],
+ },
+ {
+ label: "Sharing",
+ items: [
+ { href: "/albums", label: "Albums", icon: Library },
+ { href: "/timeline?liked=true", label: "Favorites", icon: Heart },
+ ],
+ },
+ {
+ label: "Utilities",
+ items: [
+ { href: "/duplicates", label: "Duplicates", icon: Boxes },
+ { href: "/clusters", label: "Clusters", icon: Boxes },
+ { href: "/archive", label: "Archive", icon: Archive },
+ { href: "/vault", label: "Vault", icon: LockKeyhole },
+ { href: "/trash", label: "Trash", icon: Trash2 },
+ ],
+ },
+ {
+ label: "System",
+ items: [{ href: "/settings", label: "Settings", icon: Settings }],
+ },
];
-type Theme = "light" | "dark";
-
-export default function NavBar() {
- const pathname = usePathname();
- const previousPathname = useRef(pathname);
-
- // Prevent hydration mismatch for active nav links
- const [mounted, setMounted] = useState(false);
-
- // Theme state
- const [theme, setTheme] = useState("light");
- const [isMockMode, setIsMockMode] = useState(false);
- const [isDrawerOpen, setIsDrawerOpen] = useState(false);
-
- useEffect(() => {
- setMounted(true);
-
- let initialTheme: Theme = "light";
-
- try {
- const savedTheme = localStorage.getItem("find-theme") as Theme | null;
-
- if (savedTheme === "light" || savedTheme === "dark") {
- // Use the user's previously saved preference
- initialTheme = savedTheme;
- } else {
- // No saved preference -> follow the operating system preference
- initialTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
- ? "dark"
- : "light";
- }
- } catch {
- // Fallback if localStorage is unavailable
- initialTheme = "light";
- }
-
- document.documentElement.classList.remove("light", "dark");
- document.documentElement.classList.add(initialTheme);
- document.documentElement.dataset.theme = initialTheme;
- document.documentElement.style.colorScheme = initialTheme;
-
- setTheme(initialTheme);
+function routePath(href: string) {
+ return href.split("?")[0] ?? href;
+}
- let cancelled = false;
+function isCurrentRoute(pathname: string, href: string, search: string) {
+ const path = routePath(href);
+ if (path === "/timeline" && pathname === "/timeline") {
+ const favoriteTimeline =
+ new URLSearchParams(search).get("liked") === "true";
+ return href.includes("liked=true") ? favoriteTimeline : !favoriteTimeline;
+ }
+ return pathname === path || pathname.startsWith(`${path}/`);
+}
- void getAppConfig()
- .then((config) => {
- if (!cancelled) {
- setIsMockMode(config.ml_mode === "mock");
- }
- })
- .catch(() => {
- if (!cancelled) {
- setIsMockMode(false);
- }
- });
+type NavBarProps = {
+ onNavigate?: () => void;
+ className?: string;
+};
- return () => {
- cancelled = true;
- };
- }, []);
+export default function NavBar({ onNavigate, className }: NavBarProps) {
+ const pathname = usePathname();
+ const [search, setSearch] = useState("");
useEffect(() => {
- if (previousPathname.current !== pathname) {
- previousPathname.current = pathname;
- setIsDrawerOpen(false);
- }
+ if (pathname) setSearch(window.location.search);
}, [pathname]);
- useEffect(() => {
- const handleEscape = (e: KeyboardEvent) => {
- if (e.key === "Escape") setIsDrawerOpen(false);
- };
- window.addEventListener("keydown", handleEscape);
- return () => window.removeEventListener("keydown", handleEscape);
- }, []);
-
- const toggleTheme = () => {
- const nextTheme: Theme = theme === "light" ? "dark" : "light";
-
- document.documentElement.classList.remove("light", "dark");
- document.documentElement.classList.add(nextTheme);
- document.documentElement.dataset.theme = nextTheme;
- document.documentElement.style.colorScheme = nextTheme;
-
- localStorage.setItem("find-theme", nextTheme);
- setTheme(nextTheme);
- };
-
- const navContent = (isMobile = false) => (
- <>
- {navLinks.map(({ href, label }) => {
- const isActive = mounted && pathname === href;
-
- return (
- isMobile && setIsDrawerOpen(false)}
- aria-current={isActive ? "page" : undefined}
- className={
- isActive
- ? `rounded-full bg-[color:var(--frost-soft)] px-3 py-1.5 text-sm font-medium text-[color:var(--near-white)] ${isMobile ? "w-full text-center py-3" : "sm:px-4"}`
- : `rounded-full px-3 py-1.5 text-sm font-medium text-[color:var(--silver)] transition hover:bg-[color:var(--frost-soft)] hover:text-[color:var(--near-white)] ${isMobile ? "w-full text-center py-3" : "sm:px-4"}`
- }
- >
- {label}
-
- );
- })}
-
- {!isMobile && isMockMode && (
-
-
- Mock ML Mode
-
-
- Captions, OCR, embeddings, search, and clustering use mock-backed
- data in this environment.
-
-
- )}
-
- {!isMobile && (
-
- {theme === "light" ? (
-
- ) : (
-
- )}
-
- )}
- >
- );
-
return (
- <>
-
-
- {navContent()}
-
-
-
setIsDrawerOpen(true)}
- className="flex lg:hidden h-10 w-10 items-center justify-center rounded-full border border-[var(--frost)] bg-[color:var(--frost-soft)] text-[color:var(--near-white)] transition hover:bg-[color:var(--frost)]"
- aria-label="Open navigation menu"
- >
-
-
-
-
- {mounted &&
- typeof document !== "undefined" &&
- createPortal(
- <>
- setIsDrawerOpen(false)}
- />
-
+
+ {NAV_GROUPS.map((group) => (
+
+
-
-
-
- Menu
-
- setIsDrawerOpen(false)}
- className="h-10 w-10 flex items-center justify-center rounded-full border border-[var(--frost)] bg-[color:var(--frost-soft)] text-[color:var(--near-white)] transition hover:bg-[color:var(--frost)]"
- aria-label="Close navigation menu"
- >
-
-
-
-
-
{navContent(true)}
-
-
- {isMockMode && (
-
-
- Environment Status
-
-
-
-
- Mock ML Mode Active
-
-
- OCR, embeddings, search, and clustering use
- mock-backed data.
-
-
-
- )}
-
-
-
- {theme === "light" ? (
-
- ) : (
-
- )}
- {theme === "light" ? "Dark Mode" : "Light Mode"}
-
-
- Toggle
-
-
-
-
-
- >,
- document.body,
- )}
- >
+ {group.label}
+
+
+ {group.items.map((item) => {
+ const active = isCurrentRoute(pathname, item.href, search);
+ const Icon = item.icon;
+
+ return (
+
+
+
+ {item.label}
+
+
+ );
+ })}
+
+
+ ))}
+
+
);
}
diff --git a/frontend/src/components/ai-runtime-settings.tsx b/frontend/src/components/ai-runtime-settings.tsx
new file mode 100644
index 00000000..00af41da
--- /dev/null
+++ b/frontend/src/components/ai-runtime-settings.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Boxes, Cpu, Power, ServerCog } from "lucide-react";
+import type { ReactNode } from "react";
+import { getRuntimeConfig } from "@/lib/api";
+
+interface AiRuntimeSettingsProps {
+ enabled: boolean | undefined;
+ pending: boolean;
+ onChange: (enabled: boolean) => void;
+ mode: "disabled" | "full" | "mock" | "remote" | undefined;
+ supportedModes: string[];
+ onModeChange: (mode: "disabled" | "full" | "mock") => void;
+}
+
+const PROFILE_COMMANDS = [
+ ["No AI", "docker compose -f compose.no-ai.yml up --build"],
+ ["Mock", "docker compose -f compose.mock.yml up --build"],
+ ["CPU AI", "docker compose -f compose.cpu.yml up --build"],
+ ["NVIDIA AI", "docker compose up --build"],
+] as const;
+
+export function AiRuntimeSettings({
+ enabled,
+ pending,
+ onChange,
+ mode,
+ supportedModes,
+ onModeChange,
+}: AiRuntimeSettingsProps) {
+ const runtime = useQuery({
+ queryKey: ["runtime-config"],
+ queryFn: getRuntimeConfig,
+ retry: false,
+ refetchInterval: 30_000,
+ });
+ const checked = enabled ?? runtime.data?.ai_enabled ?? false;
+
+ return (
+
+
+
+
+
+
+
+
+ Local AI runtime
+
+
+ Control new analysis jobs from here. The installed build stays
+ modular: this switch cannot download CUDA or add model packages.
+
+
+
+
onChange(!checked)}
+ className="relative h-7 w-12 shrink-0 rounded-full border border-[color:var(--frost-strong)] bg-[color:var(--surface-hover)] transition disabled:cursor-wait disabled:opacity-50 aria-checked:border-[color:var(--green)] aria-checked:bg-[color:var(--green)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--blue)]"
+ >
+
+
+
+
+
+ }
+ label="Installed artifact"
+ value={
+ runtime.data?.build_profile ??
+ (runtime.isPending ? "Detecting…" : "Unavailable")
+ }
+ />
+ }
+ label="Applied mode"
+ value={runtime.data?.applied_mode ?? "Unknown"}
+ />
+ }
+ label="Worker"
+ value={runtime.data?.worker.health.state ?? "Unknown"}
+ />
+
+
+ {(runtime.data?.unavailable_reason || runtime.data?.restart_required) && (
+
+ {runtime.data.unavailable_reason ??
+ "Start a compatible artifact to apply this runtime."}
+
+ )}
+
+
+
+ Processing mode
+
+
+ Switch instantly between the modes already installed in this build.
+
+
+ onModeChange(event.target.value as "disabled" | "full" | "mock")
+ }
+ className="mt-3 h-11 w-full rounded-xl border border-[color:var(--frost)] bg-[color:var(--void)] px-3 text-sm text-[color:var(--near-white)] outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--blue)] sm:max-w-xs"
+ >
+ {mode && !supportedModes.includes(mode) && (
+
+ {mode} (not installed)
+
+ )}
+ {supportedModes.map((supportedMode) => (
+
+ {supportedMode === "full"
+ ? "Full local AI"
+ : supportedMode === "mock"
+ ? "Mock AI"
+ : "AI disabled"}
+
+ ))}
+
+
+
+
+
+ Change the installed build
+
+
+ Choose one command on the host. Only that artifact's dependencies
+ are installed; switching from the dashboard alone would be misleading.
+
+
+ {PROFILE_COMMANDS.map(([label, command]) => (
+
+ {label}
+
+ {command}
+
+
+ ))}
+
+
+
+ );
+}
+
+function RuntimeDatum({
+ icon,
+ label,
+ value,
+}: {
+ icon: ReactNode;
+ label: string;
+ value: string;
+}) {
+ return (
+
+ {icon}
+
+
+ {label}
+
+ {value}
+
+
+ );
+}
diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx
new file mode 100644
index 00000000..ce4010f0
--- /dev/null
+++ b/frontend/src/components/app-shell.tsx
@@ -0,0 +1,302 @@
+"use client";
+
+import { Menu, Moon, Search, Sun, Upload, UserRound, X } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import NavBar from "@/components/NavBar";
+
+type Theme = "light" | "dark";
+
+type AppShellProps = {
+ children: ReactNode;
+};
+
+const FOCUSABLE_SELECTOR = [
+ "a[href]",
+ "button:not([disabled])",
+ "input:not([disabled])",
+ "select:not([disabled])",
+ "textarea:not([disabled])",
+ '[tabindex]:not([tabindex="-1"])',
+].join(",");
+
+function isShelllessRoute(pathname: string) {
+ return (
+ pathname === "/public" ||
+ pathname.startsWith("/public/") ||
+ pathname === "/auth" ||
+ pathname.startsWith("/auth/")
+ );
+}
+
+function applyTheme(theme: Theme) {
+ document.documentElement.classList.remove("light", "dark");
+ document.documentElement.classList.add(theme);
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.style.colorScheme = theme;
+}
+
+export function AppShell({ children }: AppShellProps) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [theme, setTheme] = useState("light");
+ const drawerRef = useRef(null);
+ const drawerTriggerRef = useRef(null);
+ const shellless = isShelllessRoute(pathname);
+
+ useEffect(() => {
+ let initialTheme: Theme = "light";
+
+ try {
+ const saved = localStorage.getItem("find-theme");
+ if (saved === "light" || saved === "dark") {
+ initialTheme = saved;
+ } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
+ initialTheme = "dark";
+ }
+ } catch {
+ initialTheme = "light";
+ }
+
+ applyTheme(initialTheme);
+ setTheme(initialTheme);
+ }, []);
+
+ useEffect(() => {
+ const handleSearchShortcut = (event: KeyboardEvent) => {
+ if (shellless) {
+ return;
+ }
+
+ const target = event.target;
+ const isTyping =
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target instanceof HTMLSelectElement ||
+ (target instanceof HTMLElement && target.isContentEditable);
+
+ if (isTyping) {
+ return;
+ }
+
+ const isCommandSearch =
+ event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey);
+ if (event.key === "/" || isCommandSearch) {
+ event.preventDefault();
+ router.push("/search");
+ }
+ };
+
+ window.addEventListener("keydown", handleSearchShortcut);
+ return () => window.removeEventListener("keydown", handleSearchShortcut);
+ }, [router, shellless]);
+
+ useEffect(() => {
+ if (!drawerOpen) {
+ return;
+ }
+
+ const drawer = drawerRef.current;
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+
+ const focusable = drawer
+ ? Array.from(drawer.querySelectorAll(FOCUSABLE_SELECTOR))
+ : [];
+ focusable[0]?.focus();
+
+ const handleDrawerKeys = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setDrawerOpen(false);
+ return;
+ }
+
+ if (event.key !== "Tab" || focusable.length === 0) {
+ return;
+ }
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last?.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first?.focus();
+ }
+ };
+
+ window.addEventListener("keydown", handleDrawerKeys);
+
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ window.removeEventListener("keydown", handleDrawerKeys);
+ drawerTriggerRef.current?.focus();
+ };
+ }, [drawerOpen]);
+
+ if (shellless) {
+ return <>{children}>;
+ }
+
+ const toggleTheme = () => {
+ const nextTheme: Theme = theme === "light" ? "dark" : "light";
+ applyTheme(nextTheme);
+ try {
+ localStorage.setItem("find-theme", nextTheme);
+ } catch {
+ // Theme still applies for this session when storage is unavailable.
+ }
+ setTheme(nextTheme);
+ };
+
+ return (
+
+
+
+ setDrawerOpen(true)}
+ className="icon-button lg:hidden"
+ aria-label="Open navigation menu"
+ aria-expanded={drawerOpen}
+ aria-controls="mobile-navigation"
+ >
+
+
+
+
+
+
+
+
+ FIND.
+
+
+
+
+
+
+
+ Search your library
+
+ /
+
+
+
+
+
+
+
+
+
+ Upload
+
+
+
+ {theme === "light" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
setDrawerOpen(false)}
+ className={`fixed inset-0 z-[60] bg-black/60 backdrop-blur-sm transition lg:hidden ${
+ drawerOpen
+ ? "visible opacity-100"
+ : "invisible pointer-events-none opacity-0"
+ }`}
+ />
+
+
+
+ Browse Find
+ setDrawerOpen(false)}
+ className="icon-button"
+ aria-label="Close navigation menu"
+ >
+
+
+
+ setDrawerOpen(false)}
+ className="app-shell-scrollbar min-h-0 flex-1 overflow-y-auto px-4 py-5"
+ />
+
+ Copyright 2026 Find - AGPL-3.0 License
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/frontend/src/components/asset-viewer.tsx b/frontend/src/components/asset-viewer.tsx
index b431abe9..7558074a 100644
--- a/frontend/src/components/asset-viewer.tsx
+++ b/frontend/src/components/asset-viewer.tsx
@@ -277,11 +277,11 @@ export function AssetViewer({
onPointerCancel={endPan}
onDoubleClick={onDoubleClick}
>
- {/* biome-ignore lint/a11y/useAltText: decorative full-screen media */}
+ {/* biome-ignore lint/performance/noImgElement: authenticated originals and share-scoped media bypass the Next optimizer. */}
void;
+ pending?: boolean;
}
export function HardwareAccelSettings({
value,
onChange,
+ pending = false,
}: HardwareAccelSettingsProps) {
const { data, isLoading, isError } = useQuery({
queryKey: ["hardware-report"],
queryFn: getHardwareReport,
});
-
const selected: AccelMode = value ?? data?.accel_mode ?? "auto";
return (
-
- Hardware acceleration
+
+
+
+
+
+
+
+ Hardware acceleration
+
+
+ Choose how the installed AI artifact schedules inference. Auto is
+ the safest default and falls back without interrupting uploads.
+
+
+
-
+
Acceleration mode
- {MODES.map((mode) => (
-
+ {MODES.map((mode, index) => (
+
onChange?.(mode.value)}
+ className="peer sr-only"
/>
- {mode.label}
- {mode.hint}
+
+
+ {index === 0 ? (
+
+ ) : (
+
+ )}
+ {mode.label}
+
+
+
+
+ {mode.hint}
+
))}
- {isLoading && Detecting hardware…
}
+ {isLoading && (
+
+ Detecting hardware…
+
+ )}
{isError && (
-
- Couldn't detect hardware capabilities.
+
+ Couldn't detect hardware capabilities.
)}
{data && (
-
-
- Detected:{" "}
-
+
+
+
+ Detected
+
+
{data.capabilities.has_gpu
? `GPU available (${data.capabilities.best_gpu_provider})`
: "No GPU detected — CPU only"}
-
- Currently using:{" "}
-
+
+
+ Currently using
+
+
{data.resolved.using_gpu ? "GPU" : "CPU"}
{data.resolved.notice && (
-
+
{data.resolved.notice}
)}
diff --git a/frontend/src/components/justified-grid.tsx b/frontend/src/components/justified-grid.tsx
index e91c6b7d..625e56de 100644
--- a/frontend/src/components/justified-grid.tsx
+++ b/frontend/src/components/justified-grid.tsx
@@ -42,6 +42,7 @@ interface JustifiedGridProps {
const DEFAULT_TARGET_ROW_HEIGHT = 235;
const DEFAULT_GAP = 8;
const DEFAULT_OVERSCAN_PX = 600;
+const SSR_FALLBACK_WIDTH = 1024;
// Use layout effect in the browser, plain effect during SSR to avoid warnings.
const useIsomorphicLayoutEffect =
@@ -110,7 +111,10 @@ export function JustifiedGrid({
const layout = useMemo(
() =>
computeJustifiedLayout(items, {
- containerWidth,
+ // Render a deterministic first layout during SSR/jsdom instead of an
+ // empty grid. ResizeObserver replaces this with the real width before
+ // paint in normal browsers.
+ containerWidth: containerWidth || SSR_FALLBACK_WIDTH,
targetRowHeight,
gap,
}),
diff --git a/frontend/src/components/map-privacy-settings.tsx b/frontend/src/components/map-privacy-settings.tsx
new file mode 100644
index 00000000..77c998a3
--- /dev/null
+++ b/frontend/src/components/map-privacy-settings.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { LockKeyhole, Map as MapIcon, ShieldCheck } from "lucide-react";
+import Link from "next/link";
+
+interface MapPrivacySettingsProps {
+ enabled: boolean | undefined;
+ pending: boolean;
+ onChange: (enabled: boolean) => void;
+}
+
+export function MapPrivacySettings({
+ enabled,
+ pending,
+ onChange,
+}: MapPrivacySettingsProps) {
+ const checked = enabled ?? false;
+
+ return (
+
+
+
+
+
+
+
+
+ Private photo map
+
+
+ Off by default. When enabled, newly analyzed or reprocessed photos
+ may store GPS coordinates found in their EXIF metadata. Existing
+ photos are unchanged until you reprocess them.
+
+
+
+
+
onChange(!checked)}
+ className="relative h-7 w-12 shrink-0 rounded-full border border-[color:var(--frost-strong)] bg-[color:var(--surface-hover)] transition disabled:cursor-wait disabled:opacity-50 aria-checked:border-[color:var(--orange)] aria-checked:bg-[color:var(--orange)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--void)]"
+ >
+
+
+
+
+
+
+
+
+ The map uses bundled Natural Earth geometry. It never requests
+ online map tiles or reverse geocoding.
+
+
+
+
+
+
+ Hidden, vaulted, deleted, and other users' photos stay off
+ the map.
+
+
+ {checked && (
+
+ Open map
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/map-timeline-panel.tsx b/frontend/src/components/map-timeline-panel.tsx
new file mode 100644
index 00000000..c3b61513
--- /dev/null
+++ b/frontend/src/components/map-timeline-panel.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { CalendarDays, Images, X } from "lucide-react";
+import { useMemo, useState } from "react";
+import { AssetViewer } from "@/components/asset-viewer";
+import type { MapMarker } from "@/lib/api";
+import { groupMediaByMonth } from "@/lib/media-timeline";
+import type { ViewerAsset } from "@/lib/viewer-preload";
+
+interface MapTimelinePanelProps {
+ markers: MapMarker[];
+ onClose: () => void;
+}
+
+export function MapTimelinePanel({ markers, onClose }: MapTimelinePanelProps) {
+ const [viewerIndex, setViewerIndex] = useState(null);
+ const groups = useMemo(
+ () => groupMediaByMonth(markers, (marker) => marker.created_at),
+ [markers],
+ );
+ const timelineMarkers = useMemo(
+ () => groups.flatMap((group) => group.items),
+ [groups],
+ );
+ const viewerAssets = useMemo(
+ () =>
+ timelineMarkers.map((marker) => ({
+ id: marker.id,
+ thumbnailUrl: marker.thumbnail_url,
+ originalUrl: `/api/image/${marker.id}/original`,
+ alt: marker.filename,
+ })),
+ [timelineMarkers],
+ );
+
+ let runningIndex = 0;
+
+ return (
+ <>
+
+
+ {viewerIndex !== null && viewerAssets[viewerIndex] && (
+ setViewerIndex(null)}
+ />
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/private-map.module.css b/frontend/src/components/private-map.module.css
new file mode 100644
index 00000000..e8128470
--- /dev/null
+++ b/frontend/src/components/private-map.module.css
@@ -0,0 +1,104 @@
+.shell {
+ position: relative;
+ min-height: 34rem;
+ height: min(72dvh, 54rem);
+ overflow: hidden;
+ border: 1px solid var(--frost);
+ border-radius: 1.25rem;
+ background: #09131d;
+ box-shadow: 0 20px 70px rgb(0 0 0 / 24%);
+}
+
+.canvas {
+ position: absolute;
+ inset: 0;
+}
+
+.status {
+ position: absolute;
+ z-index: 2;
+ right: 0.75rem;
+ bottom: 0.75rem;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ max-width: calc(100% - 1.5rem);
+ border: 1px solid rgb(255 255 255 / 14%);
+ border-radius: 999px;
+ background: rgb(3 9 15 / 78%);
+ padding: 0.45rem 0.7rem;
+ color: #dceeff;
+ font-size: 0.7rem;
+ line-height: 1;
+ pointer-events: none;
+ backdrop-filter: blur(14px);
+}
+
+.photoMarker,
+.clusterMarker {
+ display: grid;
+ place-items: center;
+ border: 2px solid #ff801f;
+ border-radius: 999px;
+ padding: 0;
+ color: #fff;
+ cursor: pointer;
+ box-shadow:
+ 0 8px 24px rgb(0 0 0 / 42%),
+ 0 0 0 4px rgb(255 128 31 / 18%);
+ transition:
+ transform 140ms ease,
+ border-color 140ms ease,
+ box-shadow 140ms ease;
+}
+
+.photoMarker {
+ width: 2.9rem;
+ height: 2.9rem;
+ overflow: hidden;
+ background: #162534;
+}
+
+.photoMarker img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.clusterMarker {
+ min-width: 2.9rem;
+ height: 2.9rem;
+ background: linear-gradient(145deg, #ff9a4c, #f15f00);
+ padding-inline: 0.65rem;
+ font-size: 0.78rem;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+}
+
+.photoMarker:hover,
+.photoMarker:focus-visible,
+.clusterMarker:hover,
+.clusterMarker:focus-visible {
+ z-index: 2;
+ border-color: #fff;
+ outline: none;
+ transform: scale(1.12);
+ box-shadow:
+ 0 12px 30px rgb(0 0 0 / 55%),
+ 0 0 0 5px rgb(255 255 255 / 22%);
+}
+
+.photoMarker[data-selected="true"] {
+ border-color: #fff;
+ box-shadow:
+ 0 12px 30px rgb(0 0 0 / 55%),
+ 0 0 0 6px rgb(255 128 31 / 44%);
+}
+
+@media (max-width: 760px) {
+ .shell {
+ min-height: 28rem;
+ height: 58dvh;
+ border-radius: 1rem;
+ }
+}
diff --git a/frontend/src/components/private-map.tsx b/frontend/src/components/private-map.tsx
new file mode 100644
index 00000000..0b4dcd94
--- /dev/null
+++ b/frontend/src/components/private-map.tsx
@@ -0,0 +1,522 @@
+"use client";
+
+import "maplibre-gl/dist/maplibre-gl.css";
+
+import type {
+ GeoJSONSource,
+ Map as MapLibreMap,
+ Marker as MapLibreMarker,
+ StyleSpecification,
+} from "maplibre-gl";
+import { useEffect, useRef } from "react";
+import type { MapMarker } from "@/lib/api";
+import styles from "./private-map.module.css";
+
+const LAND_SOURCE_URL = "/maps/ne_110m_land.geojson";
+const PHOTO_SOURCE_ID = "private-photos";
+const CLUSTER_LAYER_ID = "private-photo-clusters";
+const PHOTO_LAYER_ID = "private-photo-points";
+
+interface PrivateMapProps {
+ markers: MapMarker[];
+ selectedIds: ReadonlySet;
+ onSelect: (ids: number[]) => void;
+ fitRequest: number;
+}
+
+type MapLibreModule = typeof import("maplibre-gl");
+
+interface RenderedDomMarker {
+ marker: MapLibreMarker;
+ element: HTMLButtonElement;
+}
+
+export function mapMarkersToFeatureCollection(markers: readonly MapMarker[]) {
+ return {
+ type: "FeatureCollection" as const,
+ features: markers.map((marker) => ({
+ type: "Feature" as const,
+ geometry: {
+ type: "Point" as const,
+ coordinates: [marker.lon, marker.lat],
+ },
+ properties: {
+ id: marker.id,
+ },
+ })),
+ };
+}
+
+function isDarkMap(): boolean {
+ const root = document.documentElement;
+ if (root.dataset.theme === "light" || root.classList.contains("light")) {
+ return false;
+ }
+ if (root.dataset.theme === "dark" || root.classList.contains("dark")) {
+ return true;
+ }
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? true;
+}
+
+export function buildOfflineMapStyle(
+ markers: readonly MapMarker[],
+ dark: boolean,
+): StyleSpecification {
+ return {
+ version: 8,
+ sources: {
+ "offline-land": {
+ type: "geojson",
+ data: LAND_SOURCE_URL,
+ },
+ [PHOTO_SOURCE_ID]: {
+ type: "geojson",
+ data: mapMarkersToFeatureCollection(markers),
+ cluster: true,
+ clusterMaxZoom: 14,
+ clusterRadius: 48,
+ },
+ },
+ layers: [
+ {
+ id: "offline-ocean",
+ type: "background",
+ paint: {
+ "background-color": dark ? "#07121c" : "#dbeaf1",
+ },
+ },
+ {
+ id: "offline-land-fill",
+ type: "fill",
+ source: "offline-land",
+ paint: {
+ "fill-color": dark ? "#172a36" : "#f4efe5",
+ "fill-opacity": 1,
+ },
+ },
+ {
+ id: "offline-land-line",
+ type: "line",
+ source: "offline-land",
+ paint: {
+ "line-color": dark ? "#355262" : "#b9c5c8",
+ "line-width": 0.75,
+ "line-opacity": 0.8,
+ },
+ },
+ {
+ id: CLUSTER_LAYER_ID,
+ type: "circle",
+ source: PHOTO_SOURCE_ID,
+ filter: ["has", "point_count"],
+ paint: {
+ "circle-color": "#ff801f",
+ "circle-radius": [
+ "step",
+ ["get", "point_count"],
+ 20,
+ 10,
+ 23,
+ 100,
+ 27,
+ ],
+ "circle-opacity": 0.28,
+ "circle-stroke-color": "#ffb77e",
+ "circle-stroke-width": 1,
+ },
+ },
+ {
+ id: PHOTO_LAYER_ID,
+ type: "circle",
+ source: PHOTO_SOURCE_ID,
+ filter: ["!", ["has", "point_count"]],
+ paint: {
+ "circle-color": "#ff801f",
+ "circle-radius": 18,
+ "circle-opacity": 0.2,
+ "circle-stroke-color": "#ffb77e",
+ "circle-stroke-width": 1,
+ },
+ },
+ ],
+ };
+}
+
+/** Return the smallest longitude interval, including across the dateline. */
+export function calculateMapBounds(
+ markers: readonly MapMarker[],
+): [west: number, south: number, east: number, north: number] | null {
+ if (markers.length === 0) {
+ return null;
+ }
+
+ const longitudes = markers
+ .map((marker) => ((marker.lon % 360) + 360) % 360)
+ .sort((left, right) => left - right);
+ let largestGap = -1;
+ let gapStartIndex = 0;
+ for (let index = 0; index < longitudes.length; index += 1) {
+ const current = longitudes[index];
+ const next =
+ index === longitudes.length - 1
+ ? (longitudes[0] ?? 0) + 360
+ : longitudes[index + 1];
+ if (current === undefined || next === undefined) {
+ continue;
+ }
+ const gap = next - current;
+ if (gap > largestGap) {
+ largestGap = gap;
+ gapStartIndex = index;
+ }
+ }
+
+ const westIndex = (gapStartIndex + 1) % longitudes.length;
+ let west = longitudes[westIndex] ?? markers[0]?.lon ?? 0;
+ let east = longitudes[gapStartIndex] ?? west;
+ if (east < west) {
+ east += 360;
+ }
+ if (west > 180) {
+ west -= 360;
+ east -= 360;
+ }
+
+ const latitudes = markers.map((marker) => marker.lat);
+ return [west, Math.min(...latitudes), east, Math.max(...latitudes)];
+}
+
+function fitMarkers(
+ map: MapLibreMap,
+ maplibre: MapLibreModule,
+ markers: readonly MapMarker[],
+): void {
+ if (markers.length === 0) {
+ map.easeTo({ center: [15, 20], zoom: 1.25, duration: 350 });
+ return;
+ }
+
+ const first = markers[0];
+ if (!first) {
+ return;
+ }
+ if (markers.length === 1) {
+ map.easeTo({
+ center: [first.lon, first.lat],
+ zoom: 10,
+ duration: 450,
+ });
+ return;
+ }
+
+ const coordinates = calculateMapBounds(markers);
+ if (!coordinates) {
+ return;
+ }
+ const [west, south, east, north] = coordinates;
+ const bounds = new maplibre.LngLatBounds([west, south], [east, north]);
+ map.fitBounds(bounds, {
+ padding: 64,
+ maxZoom: 11,
+ duration: 500,
+ });
+}
+
+function applyMapPalette(map: MapLibreMap): void {
+ if (!map.isStyleLoaded()) {
+ return;
+ }
+ const dark = isDarkMap();
+ map.setPaintProperty(
+ "offline-ocean",
+ "background-color",
+ dark ? "#07121c" : "#dbeaf1",
+ );
+ map.setPaintProperty(
+ "offline-land-fill",
+ "fill-color",
+ dark ? "#172a36" : "#f4efe5",
+ );
+ map.setPaintProperty(
+ "offline-land-line",
+ "line-color",
+ dark ? "#355262" : "#b9c5c8",
+ );
+}
+
+export function PrivateMap({
+ markers,
+ selectedIds,
+ onSelect,
+ fitRequest,
+}: PrivateMapProps) {
+ const containerRef = useRef(null);
+ const mapRef = useRef(null);
+ const maplibreRef = useRef(null);
+ const markersRef = useRef(markers);
+ const selectedIdsRef = useRef(selectedIds);
+ const onSelectRef = useRef(onSelect);
+ const syncMarkersRef = useRef<() => void>(() => {});
+ const lastFitRequestRef = useRef(fitRequest);
+
+ markersRef.current = markers;
+ selectedIdsRef.current = selectedIds;
+ onSelectRef.current = onSelect;
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ let disposed = false;
+ let themeObserver: MutationObserver | null = null;
+ let resizeObserver: ResizeObserver | null = null;
+ const rendered = new Map();
+
+ void import("maplibre-gl").then((maplibre) => {
+ if (disposed || !containerRef.current) {
+ return;
+ }
+
+ maplibreRef.current = maplibre;
+ const map = new maplibre.Map({
+ container: containerRef.current,
+ style: buildOfflineMapStyle(markersRef.current, isDarkMap()),
+ center: [15, 20],
+ zoom: 1.25,
+ minZoom: 0.75,
+ maxZoom: 18,
+ renderWorldCopies: false,
+ attributionControl: false,
+ });
+ mapRef.current = map;
+ map.addControl(
+ new maplibre.NavigationControl({ showCompass: false }),
+ "top-left",
+ );
+
+ const chooseCluster = async (clusterId: number) => {
+ const source = map.getSource(PHOTO_SOURCE_ID) as
+ | GeoJSONSource
+ | undefined;
+ if (!source) {
+ return;
+ }
+ const leaves = await source.getClusterLeaves(clusterId, 10_000, 0);
+ const ids = leaves
+ .map((leaf) => Number(leaf.properties?.id))
+ .filter((id) => Number.isFinite(id));
+ if (ids.length === 0) {
+ return;
+ }
+ onSelectRef.current(ids);
+ const idSet = new Set(ids);
+ fitMarkers(
+ map,
+ maplibre,
+ markersRef.current.filter((marker) => idSet.has(marker.id)),
+ );
+ };
+
+ const syncMarkers = () => {
+ if (
+ !map.isStyleLoaded() ||
+ !map.getLayer(CLUSTER_LAYER_ID) ||
+ !map.getLayer(PHOTO_LAYER_ID)
+ ) {
+ return;
+ }
+
+ const visibleKeys = new Set();
+ const clusterFeatures = map.queryRenderedFeatures({
+ layers: [CLUSTER_LAYER_ID],
+ });
+ for (const feature of clusterFeatures) {
+ if (feature.geometry.type !== "Point") {
+ continue;
+ }
+ const clusterId = Number(feature.properties?.cluster_id);
+ const count = Number(feature.properties?.point_count);
+ if (!Number.isFinite(clusterId) || !Number.isFinite(count)) {
+ continue;
+ }
+ const key = `cluster-${clusterId}`;
+ if (visibleKeys.has(key)) {
+ continue;
+ }
+ visibleKeys.add(key);
+ const coordinates = feature.geometry.coordinates as [number, number];
+ const current = rendered.get(key);
+ if (current) {
+ current.marker.setLngLat(coordinates);
+ current.element.textContent = count.toLocaleString();
+ continue;
+ }
+
+ const element = document.createElement("button");
+ element.type = "button";
+ element.className = styles.clusterMarker ?? "";
+ element.textContent = count.toLocaleString();
+ element.setAttribute(
+ "aria-label",
+ `${count.toLocaleString()} photos in this area`,
+ );
+ element.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ void chooseCluster(clusterId);
+ });
+ rendered.set(key, {
+ marker: new maplibre.Marker({ element })
+ .setLngLat(coordinates)
+ .addTo(map),
+ element,
+ });
+ }
+
+ const markerById = new Map(
+ markersRef.current.map((marker) => [marker.id, marker]),
+ );
+ const photoFeatures = map.queryRenderedFeatures({
+ layers: [PHOTO_LAYER_ID],
+ });
+ for (const feature of photoFeatures) {
+ if (feature.geometry.type !== "Point") {
+ continue;
+ }
+ const id = Number(feature.properties?.id);
+ const photo = markerById.get(id);
+ if (!photo) {
+ continue;
+ }
+ const key = `photo-${id}`;
+ if (visibleKeys.has(key)) {
+ continue;
+ }
+ visibleKeys.add(key);
+ const coordinates = feature.geometry.coordinates as [number, number];
+ const current = rendered.get(key);
+ if (current) {
+ current.marker.setLngLat(coordinates);
+ current.element.dataset.selected = String(
+ selectedIdsRef.current.has(id),
+ );
+ continue;
+ }
+
+ const element = document.createElement("button");
+ element.type = "button";
+ element.className = styles.photoMarker ?? "";
+ element.dataset.selected = String(selectedIdsRef.current.has(id));
+ element.setAttribute(
+ "aria-label",
+ `Show ${photo.filename} on timeline`,
+ );
+
+ const image = document.createElement("img");
+ image.src = photo.thumbnail_url;
+ image.alt = "";
+ image.loading = "lazy";
+ element.append(image);
+ element.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onSelectRef.current([id]);
+ });
+ rendered.set(key, {
+ marker: new maplibre.Marker({ element })
+ .setLngLat(coordinates)
+ .addTo(map),
+ element,
+ });
+ }
+
+ for (const [key, entry] of rendered) {
+ if (!visibleKeys.has(key)) {
+ entry.marker.remove();
+ rendered.delete(key);
+ }
+ }
+ };
+ syncMarkersRef.current = syncMarkers;
+
+ map.on("load", () => {
+ const source = map.getSource(PHOTO_SOURCE_ID) as
+ | GeoJSONSource
+ | undefined;
+ source?.setData(mapMarkersToFeatureCollection(markersRef.current));
+ applyMapPalette(map);
+ fitMarkers(map, maplibre, markersRef.current);
+ syncMarkers();
+ });
+ map.on("idle", syncMarkers);
+ map.on("moveend", syncMarkers);
+ map.on("zoomend", syncMarkers);
+
+ themeObserver = new MutationObserver(() => applyMapPalette(map));
+ themeObserver.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class", "data-theme"],
+ });
+
+ resizeObserver = new ResizeObserver(() => map.resize());
+ resizeObserver.observe(container);
+ });
+
+ return () => {
+ disposed = true;
+ themeObserver?.disconnect();
+ resizeObserver?.disconnect();
+ syncMarkersRef.current = () => {};
+ for (const entry of rendered.values()) {
+ entry.marker.remove();
+ }
+ rendered.clear();
+ mapRef.current?.remove();
+ mapRef.current = null;
+ maplibreRef.current = null;
+ };
+ }, []);
+
+ useEffect(() => {
+ const map = mapRef.current;
+ const maplibre = maplibreRef.current;
+ if (!map || !maplibre || !map.isStyleLoaded()) {
+ return;
+ }
+ const source = map.getSource(PHOTO_SOURCE_ID) as GeoJSONSource | undefined;
+ source?.setData(mapMarkersToFeatureCollection(markers));
+ map.once("idle", () => {
+ syncMarkersRef.current();
+ fitMarkers(map, maplibre, markers);
+ });
+ }, [markers]);
+
+ useEffect(() => {
+ selectedIdsRef.current = selectedIds;
+ syncMarkersRef.current();
+ }, [selectedIds]);
+
+ useEffect(() => {
+ if (lastFitRequestRef.current === fitRequest) {
+ return;
+ }
+ lastFitRequestRef.current = fitRequest;
+ const map = mapRef.current;
+ const maplibre = maplibreRef.current;
+ if (map && maplibre) {
+ fitMarkers(map, maplibre, markersRef.current);
+ }
+ }, [fitRequest]);
+
+ return (
+
+
+
+ ●
+ Local Natural Earth map · no tile or geocoding requests
+
+
+ );
+}
diff --git a/frontend/src/components/timeline-media-view.tsx b/frontend/src/components/timeline-media-view.tsx
new file mode 100644
index 00000000..a8cc2404
--- /dev/null
+++ b/frontend/src/components/timeline-media-view.tsx
@@ -0,0 +1,397 @@
+"use client";
+
+/**
+ * Route-agnostic, date-grouped media timeline.
+ *
+ * Its month sections, justified rows, fast date scrubber, and scroll-position
+ * mapping are adapted from the AGPL-3.0 reference project's timeline behavior.
+ * Original copyright belongs to its authors. This file is part of Find and is
+ * distributed under AGPL-3.0. See NOTICE.
+ */
+
+import {
+ type ReactNode,
+ type RefObject,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { AssetViewer } from "@/components/asset-viewer";
+import { JustifiedGrid } from "@/components/justified-grid";
+import { TimelineScrubber } from "@/components/timeline-scrubber";
+import {
+ actualOffsetToScrubberOffset,
+ groupMediaByMonth,
+ mediaAspectRatio,
+ scrubberOffsetToActualOffset,
+ type TimelineSectionMeasurement,
+ timelineBucketsFromGroups,
+} from "@/lib/media-timeline";
+import {
+ buildScrubberLayout,
+ type ScrubberOptions,
+} from "@/lib/timeline-scrubber";
+import type { ViewerAsset } from "@/lib/viewer-preload";
+
+const SCROLL_ANCHOR_PX = 96;
+
+export interface TimelineMediaViewerRenderProps {
+ items: T[];
+ viewerAssets: ViewerAsset[];
+ index: number;
+ onIndexChange: (index: number) => void;
+ onClose: () => void;
+}
+
+interface TimelineMediaViewProps {
+ items: readonly T[];
+ getId: (item: T) => number;
+ getDate: (item: T) => string | null | undefined;
+ getWidth?: (item: T) => number | null | undefined;
+ getHeight?: (item: T) => number | null | undefined;
+ getThumbnailUrl: (item: T) => string | null | undefined;
+ getOriginalUrl: (item: T) => string | null | undefined;
+ getAlt?: (item: T) => string;
+ getOpenLabel?: (item: T) => string;
+ getItemTestId?: (item: T) => string;
+ getOpenTestId?: (item: T) => string;
+ onOpenItem?: (item: T, index: number) => void;
+ renderItemActions?: (item: T) => ReactNode;
+ renderViewer?: (props: TimelineMediaViewerRenderProps) => ReactNode;
+ order?: "newest" | "oldest";
+ empty?: ReactNode;
+ className?: string;
+ controlsId?: string;
+ scrollContainerRef?: RefObject;
+}
+
+function useResponsiveRowHeight(): number {
+ const [rowHeight, setRowHeight] = useState(220);
+
+ useEffect(() => {
+ const update = () => setRowHeight(window.innerWidth < 768 ? 120 : 220);
+ update();
+ window.addEventListener("resize", update);
+ return () => window.removeEventListener("resize", update);
+ }, []);
+
+ return rowHeight;
+}
+
+export function TimelineMediaView({
+ items,
+ getId,
+ getDate,
+ getWidth = () => null,
+ getHeight = () => null,
+ getThumbnailUrl,
+ getOriginalUrl,
+ getAlt = () => "",
+ getOpenLabel,
+ getItemTestId,
+ getOpenTestId,
+ onOpenItem,
+ renderItemActions,
+ renderViewer,
+ order = "newest",
+ empty,
+ className,
+ controlsId = "route-media-timeline",
+ scrollContainerRef,
+}: TimelineMediaViewProps) {
+ const rootRef = useRef(null);
+ const sectionRefs = useRef(new Map());
+ const [scrollOffset, setScrollOffset] = useState(0);
+ const [viewerIndex, setViewerIndex] = useState(null);
+ const targetRowHeight = useResponsiveRowHeight();
+
+ const groups = useMemo(
+ () => groupMediaByMonth(items, getDate, order),
+ [getDate, items, order],
+ );
+ const timelineItems = useMemo(
+ () => groups.flatMap((group) => group.items),
+ [groups],
+ );
+ const buckets = useMemo(() => timelineBucketsFromGroups(groups), [groups]);
+ const layoutOptions = useMemo(
+ () => ({
+ columnsPerRow: targetRowHeight <= 120 ? 3 : 5,
+ gap: 6,
+ headerHeight: 44,
+ targetRowHeight,
+ }),
+ [targetRowHeight],
+ );
+ const scrubberLayout = useMemo(
+ () => buildScrubberLayout(buckets, layoutOptions),
+ [buckets, layoutOptions],
+ );
+
+ const viewerAssets = useMemo(
+ () =>
+ timelineItems.map((item) => {
+ const thumbnailUrl = getThumbnailUrl(item) ?? "";
+ return {
+ id: getId(item),
+ thumbnailUrl,
+ alt: getAlt(item),
+ // View-only shares deliberately fall back to their share-scoped
+ // thumbnail; a private route is never synthesized here.
+ originalUrl: getOriginalUrl(item) ?? thumbnailUrl,
+ };
+ }),
+ [getAlt, getId, getOriginalUrl, getThumbnailUrl, timelineItems],
+ );
+
+ const measurements = useCallback((): TimelineSectionMeasurement[] => {
+ const root = rootRef.current;
+ if (!root || typeof window === "undefined") {
+ return [];
+ }
+
+ const rootTop = root.getBoundingClientRect().top;
+ return groups.flatMap((group) => {
+ const section = sectionRefs.current.get(group.timeBucket);
+ if (!section) {
+ return [];
+ }
+ const rect = section.getBoundingClientRect();
+ return [
+ {
+ timeBucket: group.timeBucket,
+ top: rect.top - rootTop,
+ height: Math.max(rect.height, section.offsetHeight),
+ },
+ ];
+ });
+ }, [groups]);
+
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const update = () => {
+ const root = rootRef.current;
+ if (!root) {
+ return;
+ }
+ const container = scrollContainerRef?.current;
+ const rootTop = container
+ ? root.getBoundingClientRect().top -
+ container.getBoundingClientRect().top +
+ container.scrollTop
+ : root.getBoundingClientRect().top + window.scrollY;
+ const scrollPosition = container?.scrollTop ?? window.scrollY;
+ const actualOffset = scrollPosition + SCROLL_ANCHOR_PX - rootTop;
+ setScrollOffset(
+ actualOffsetToScrubberOffset(
+ measurements(),
+ scrubberLayout,
+ actualOffset,
+ ),
+ );
+ };
+
+ const scrollTarget = scrollContainerRef?.current ?? window;
+ update();
+ scrollTarget.addEventListener("scroll", update, { passive: true });
+ window.addEventListener("resize", update);
+ return () => {
+ scrollTarget.removeEventListener("scroll", update);
+ window.removeEventListener("resize", update);
+ };
+ }, [measurements, scrollContainerRef, scrubberLayout]);
+
+ useEffect(() => {
+ if (
+ viewerIndex !== null &&
+ (viewerIndex < 0 || viewerIndex >= timelineItems.length)
+ ) {
+ setViewerIndex(null);
+ }
+ }, [timelineItems.length, viewerIndex]);
+
+ const handleScrub = useCallback(
+ (nextOffset: number) => {
+ const root = rootRef.current;
+ if (!root || typeof window === "undefined") {
+ return;
+ }
+
+ const actualOffset = scrubberOffsetToActualOffset(
+ measurements(),
+ scrubberLayout,
+ nextOffset,
+ );
+ const container = scrollContainerRef?.current;
+ const rootTop = container
+ ? root.getBoundingClientRect().top -
+ container.getBoundingClientRect().top +
+ container.scrollTop
+ : root.getBoundingClientRect().top + window.scrollY;
+ setScrollOffset(nextOffset);
+ const top = Math.max(0, rootTop + actualOffset - SCROLL_ANCHOR_PX);
+ if (container) container.scrollTo({ top, behavior: "auto" });
+ else window.scrollTo({ top, behavior: "auto" });
+ },
+ [measurements, scrollContainerRef, scrubberLayout],
+ );
+
+ if (timelineItems.length === 0) {
+ return <>{empty}>;
+ }
+
+ let runningIndex = 0;
+
+ return (
+ <>
+
+
+ {groups.map((group) => {
+ const groupStartIndex = runningIndex;
+ runningIndex += group.items.length;
+
+ return (
+
{
+ if (element) {
+ sectionRefs.current.set(group.timeBucket, element);
+ } else {
+ sectionRefs.current.delete(group.timeBucket);
+ }
+ }}
+ data-testid={`timeline-group-${group.timeBucket}`}
+ aria-labelledby={`timeline-heading-${group.timeBucket}`}
+ style={{ marginBottom: 28, scrollMarginTop: SCROLL_ANCHOR_PX }}
+ >
+
+ {group.label}
+
+ {group.items.length}
+
+
+ ({
+ item,
+ ratio: mediaAspectRatio(getWidth(item), getHeight(item)),
+ }))}
+ targetRowHeight={targetRowHeight}
+ gap={6}
+ getKey={({ item }) => getId(item)}
+ renderItem={({ item }, index) => {
+ const timelineIndex = groupStartIndex + index;
+ const thumbnailUrl = getThumbnailUrl(item);
+ return (
+
+ {
+ if (onOpenItem) onOpenItem(item, timelineIndex);
+ else setViewerIndex(timelineIndex);
+ }}
+ className="block h-full w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--blue)] focus-visible:ring-inset"
+ >
+ {thumbnailUrl ? (
+ // biome-ignore lint/performance/noImgElement: authenticated and share-scoped media URLs are not Next image assets.
+
+ ) : (
+
+ No preview
+
+ )}
+
+ {renderItemActions && (
+
+ {renderItemActions(item)}
+
+ )}
+
+ );
+ }}
+ />
+
+ );
+ })}
+
+
+
+
+
+ {viewerIndex !== null &&
+ viewerAssets[viewerIndex] &&
+ (renderViewer ? (
+ renderViewer({
+ items: timelineItems,
+ viewerAssets,
+ index: viewerIndex,
+ onIndexChange: setViewerIndex,
+ onClose: () => setViewerIndex(null),
+ })
+ ) : (
+ setViewerIndex(null)}
+ />
+ ))}
+ >
+ );
+}
diff --git a/frontend/src/components/vault/VaultGallery.tsx b/frontend/src/components/vault/VaultGallery.tsx
index f79552b2..4a9a9e16 100644
--- a/frontend/src/components/vault/VaultGallery.tsx
+++ b/frontend/src/components/vault/VaultGallery.tsx
@@ -1,138 +1,292 @@
"use client";
-import { useQuery } from "@tanstack/react-query";
-import axios from "axios";
-import { ImageOff, Loader2, Lock } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { api } from "@/lib/api";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ImageOff, Loader2, Lock, RotateCcw } from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { AssetViewer } from "@/components/asset-viewer";
+import {
+ TimelineMediaView,
+ type TimelineMediaViewerRenderProps,
+} from "@/components/timeline-media-view";
import { vaultStore } from "@/store/vaultStore";
import { VaultUnlock } from "./VaultUnlock";
+import {
+ fetchVaultOriginal,
+ fetchVaultThumbnail,
+ isExpiredVaultSession,
+ listVaultItems,
+ lockVaultSession,
+ restoreVaultItem,
+ type VaultListItem,
+} from "./vault-client";
-type VaultListItem = {
- id: number;
- filename: string;
- content_type: string | null;
- created_at: string | null;
-};
+const VAULT_QUERY_KEY = ["vault-gallery"] as const;
+const THUMBNAIL_CONCURRENCY = 3;
+
+interface VaultViewerProps
+ extends TimelineMediaViewerRenderProps {
+ sessionToken: string;
+ thumbnailUrls: Readonly>;
+ onSessionExpired: () => void;
+ onLoadError: () => void;
+}
+
+function VaultViewer({
+ items,
+ index,
+ onIndexChange,
+ onClose,
+ sessionToken,
+ thumbnailUrls,
+ onSessionExpired,
+ onLoadError,
+}: VaultViewerProps) {
+ const activeId = items[index]?.id;
+ const [original, setOriginal] = useState<{
+ mediaId: number;
+ url: string;
+ } | null>(null);
+ const handlersRef = useRef({ onClose, onSessionExpired, onLoadError });
+
+ useEffect(() => {
+ handlersRef.current = { onClose, onSessionExpired, onLoadError };
+ }, [onClose, onLoadError, onSessionExpired]);
+
+ useEffect(() => {
+ if (!activeId) {
+ return;
+ }
+
+ let cancelled = false;
+ let objectUrl: string | null = null;
+ setOriginal(null);
+
+ void fetchVaultOriginal(activeId, sessionToken)
+ .then((blob) => {
+ if (cancelled) {
+ return;
+ }
+ objectUrl = URL.createObjectURL(blob);
+ setOriginal({ mediaId: activeId, url: objectUrl });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) {
+ return;
+ }
+ if (isExpiredVaultSession(error)) {
+ handlersRef.current.onClose();
+ handlersRef.current.onSessionExpired();
+ return;
+ }
+ handlersRef.current.onLoadError();
+ });
+
+ return () => {
+ cancelled = true;
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ };
+ }, [activeId, sessionToken]);
+
+ const assets = useMemo(
+ () =>
+ items.map((item) => {
+ const thumbnailUrl = thumbnailUrls[item.id] ?? "";
+ return {
+ id: item.id,
+ thumbnailUrl,
+ originalUrl:
+ original?.mediaId === item.id ? original.url : thumbnailUrl,
+ };
+ }),
+ [items, original, thumbnailUrls],
+ );
+
+ return (
+
+ );
+}
export function VaultGallery() {
+ const queryClient = useQueryClient();
const isUnlocked = vaultStore((state) => state.isUnlocked);
const sessionToken = vaultStore((state) => state.sessionToken);
const [sessionMessage, setSessionMessage] = useState(null);
- const [streamUrls, setStreamUrls] = useState>({});
+ const [thumbnailUrls, setThumbnailUrls] = useState>(
+ {},
+ );
const objectUrlsRef = useRef>({});
+ const revokeThumbnails = useCallback(() => {
+ for (const url of Object.values(objectUrlsRef.current)) {
+ URL.revokeObjectURL(url);
+ }
+ objectUrlsRef.current = {};
+ setThumbnailUrls({});
+ }, []);
+
+ const clearSession = useCallback(
+ (message: string) => {
+ revokeThumbnails();
+ queryClient.removeQueries({ queryKey: VAULT_QUERY_KEY });
+ vaultStore.getState().lock();
+ setSessionMessage(message);
+ },
+ [queryClient, revokeThumbnails],
+ );
+
const listQuery = useQuery({
- queryKey: ["vault-gallery", sessionToken],
+ queryKey: VAULT_QUERY_KEY,
enabled: isUnlocked && !!sessionToken,
- queryFn: async () => {
- const response = await api.get("/api/vault/list", {
- headers: {
- Authorization: `Bearer ${sessionToken}`,
- },
- });
- return response.data;
- },
+ queryFn: () => listVaultItems(sessionToken ?? ""),
});
useEffect(() => {
- if (!listQuery.error || !axios.isAxiosError(listQuery.error)) {
+ if (!listQuery.error) {
return;
}
- if (listQuery.error.response?.status === 401) {
- vaultStore.getState().lock();
- setSessionMessage("Session expired. Please unlock again.");
+ if (isExpiredVaultSession(listQuery.error)) {
+ clearSession("Session expired. Please unlock again.");
}
- }, [listQuery.error]);
+ }, [clearSession, listQuery.error]);
useEffect(() => {
if (!isUnlocked) {
- Object.values(objectUrlsRef.current).forEach((url) => {
- URL.revokeObjectURL(url);
- });
- objectUrlsRef.current = {};
- setStreamUrls({});
+ revokeThumbnails();
return;
}
-
setSessionMessage(null);
- }, [isUnlocked]);
+ }, [isUnlocked, revokeThumbnails]);
useEffect(() => {
- if (!isUnlocked || !sessionToken || !listQuery.data) {
+ const items = listQuery.data;
+ if (!isUnlocked || !sessionToken || !items) {
return;
}
- let cancelled = false;
+ const currentIds = new Set(items.map((item) => item.id));
+ for (const [rawId, url] of Object.entries(objectUrlsRef.current)) {
+ const mediaId = Number(rawId);
+ if (!currentIds.has(mediaId)) {
+ URL.revokeObjectURL(url);
+ delete objectUrlsRef.current[mediaId];
+ }
+ }
+ setThumbnailUrls({ ...objectUrlsRef.current });
- const loadStreams = async () => {
- const nextUrls: Record = {};
-
- try {
- await Promise.all(
- listQuery.data.map(async (item) => {
- const response = await api.get(
- `/api/vault/stream/${item.id}`,
- {
- headers: {
- Authorization: `Bearer ${sessionToken}`,
- },
- responseType: "blob",
- },
- );
-
- if (!cancelled) {
- nextUrls[item.id] = URL.createObjectURL(response.data);
- }
- }),
- );
+ const queue = items.filter((item) => !objectUrlsRef.current[item.id]);
+ let cursor = 0;
+ let cancelled = false;
- if (cancelled) {
- Object.values(nextUrls).forEach((url) => {
- URL.revokeObjectURL(url);
- });
+ const worker = async () => {
+ while (!cancelled) {
+ const item = queue[cursor];
+ cursor += 1;
+ if (!item) {
return;
}
- Object.values(objectUrlsRef.current).forEach((url) => {
- URL.revokeObjectURL(url);
- });
- objectUrlsRef.current = nextUrls;
- setStreamUrls(nextUrls);
- } catch (error) {
- Object.values(nextUrls).forEach((url) => {
- URL.revokeObjectURL(url);
- });
-
- if (axios.isAxiosError(error) && error.response?.status === 401) {
- vaultStore.getState().lock();
- setSessionMessage("Session expired. Please unlock again.");
- } else {
+ try {
+ const blob = await fetchVaultThumbnail(item.id, sessionToken);
+ if (cancelled) {
+ return;
+ }
+ const url = URL.createObjectURL(blob);
+ objectUrlsRef.current[item.id] = url;
+ setThumbnailUrls((current) => ({ ...current, [item.id]: url }));
+ } catch (error) {
+ if (cancelled) {
+ return;
+ }
+ if (isExpiredVaultSession(error)) {
+ cancelled = true;
+ clearSession("Session expired. Please unlock again.");
+ return;
+ }
setSessionMessage(
- "Some images could not be decrypted. Please try again.",
+ "Some encrypted previews could not be loaded. You can retry shortly.",
);
}
}
};
- void loadStreams();
+ const workers = Array.from(
+ { length: Math.min(THUMBNAIL_CONCURRENCY, queue.length) },
+ () => worker(),
+ );
+ void Promise.all(workers);
return () => {
cancelled = true;
};
- }, [isUnlocked, listQuery.data, sessionToken]);
+ }, [clearSession, isUnlocked, listQuery.data, sessionToken]);
- useEffect(() => {
- return () => {
- Object.values(objectUrlsRef.current).forEach((url) => {
- URL.revokeObjectURL(url);
+ useEffect(() => revokeThumbnails, [revokeThumbnails]);
+
+ const restoreMutation = useMutation({
+ mutationFn: async (mediaId: number) => {
+ if (!sessionToken) {
+ throw new Error("Vault session missing");
+ }
+ await restoreVaultItem(mediaId, sessionToken);
+ return mediaId;
+ },
+ onSuccess: async (mediaId) => {
+ const thumbnailUrl = objectUrlsRef.current[mediaId];
+ if (thumbnailUrl) {
+ URL.revokeObjectURL(thumbnailUrl);
+ delete objectUrlsRef.current[mediaId];
+ setThumbnailUrls({ ...objectUrlsRef.current });
+ }
+ setSessionMessage("Image restored to your timeline.");
+ await queryClient.invalidateQueries({ queryKey: VAULT_QUERY_KEY });
+ },
+ onError: (error) => {
+ if (isExpiredVaultSession(error)) {
+ clearSession("Session expired. Please unlock again.");
+ return;
+ }
+ setSessionMessage(
+ "The image could not be restored. Its encrypted copy is still safe.",
+ );
+ },
+ });
+
+ const handleLock = useCallback(() => {
+ const token = sessionToken;
+ revokeThumbnails();
+ queryClient.removeQueries({ queryKey: VAULT_QUERY_KEY });
+ vaultStore.getState().lock();
+ setSessionMessage(null);
+
+ if (token) {
+ void lockVaultSession(token).catch((error: unknown) => {
+ if (!isExpiredVaultSession(error)) {
+ setSessionMessage(
+ "Vault locked here. The server session will expire automatically.",
+ );
+ }
});
- objectUrlsRef.current = {};
- };
+ }
+ }, [queryClient, revokeThumbnails, sessionToken]);
+
+ const handleViewerError = useCallback(() => {
+ setSessionMessage(
+ "The full encrypted image could not be opened. Its preview remains available.",
+ );
}, []);
+ const handleSessionExpired = useCallback(() => {
+ clearSession("Session expired. Please unlock again.");
+ }, [clearSession]);
- if (!isUnlocked) {
+ if (!isUnlocked || !sessionToken) {
return (
@@ -149,21 +303,22 @@ export function VaultGallery() {
return (
-
-
+
+
-
- Hidden Vault
-
-
- Encrypted images unlocked for this session only.
+
+ Locked Vault
+
+
+ Originals stay encrypted at rest. This session exists in memory
+ only.
vaultStore.getState().lock()}
- className="inline-flex items-center gap-2 rounded-full border border-[var(--frost)] px-4 py-2 text-xs font-medium text-[color:var(--silver)] transition-colors hover:bg-[color:var(--frost-soft)] hover:text-[color:var(--near-white)]"
+ onClick={handleLock}
+ className="inline-flex items-center justify-center gap-2 rounded-full border border-[var(--frost)] px-4 py-2 text-xs font-medium text-[color:var(--silver)] transition-colors hover:bg-[color:var(--frost-soft)] hover:text-[color:var(--near-white)]"
>
Lock Vault
@@ -171,7 +326,9 @@ export function VaultGallery() {
{sessionMessage && (
-
{sessionMessage}
+
+ {sessionMessage}
+
)}
{listQuery.isLoading && (
@@ -180,68 +337,66 @@ export function VaultGallery() {
)}
- {listQuery.isError && !sessionMessage && (
+ {listQuery.isError && !isExpiredVaultSession(listQuery.error) && (
)}
- {listQuery.data && listQuery.data.length === 0 && (
-
-
-
-
- No hidden images yet
-
-
-
- )}
-
- {listQuery.data && listQuery.data.length > 0 && (
-
- {listQuery.data.map((item) => {
- const imageSrc = streamUrls[item.id];
-
- return (
-
-
- {imageSrc ? (
- // biome-ignore lint/performance/noImgElement: Vault previews use authenticated blob URLs from decrypted local streams, not public image URLs.
-
- ) : (
-
-
- Decrypting
-
- )}
-
-
-
-
-
- {item.filename}
-
-
- {item.created_at
- ? new Date(item.created_at).toLocaleString()
- : "Unknown date"}
-
-
-
- );
- })}
-
+ {listQuery.data && (
+
item.id}
+ getDate={(item) => item.created_at}
+ getWidth={(item) => item.width}
+ getHeight={(item) => item.height}
+ getThumbnailUrl={(item) => thumbnailUrls[item.id]}
+ getOriginalUrl={(item) => thumbnailUrls[item.id]}
+ getAlt={(item) => item.filename}
+ getItemTestId={(item) => `vault-item-${item.id}`}
+ getOpenTestId={(item) => `open-vault-item-${item.id}`}
+ controlsId="vault-media-timeline"
+ empty={
+
+
+
+ No locked images yet
+
+
+ Unlock the vault from Gallery to move images here.
+
+
+ }
+ renderItemActions={(item) => (
+ restoreMutation.mutate(item.id)}
+ className="inline-flex items-center gap-1 rounded-full bg-black/65 px-3 py-1.5 text-xs font-medium text-white backdrop-blur transition hover:bg-black/85 disabled:cursor-wait disabled:opacity-60"
+ >
+ {restoreMutation.isPending &&
+ restoreMutation.variables === item.id ? (
+
+ ) : (
+
+ )}
+ Restore
+
+ )}
+ renderViewer={(props) => (
+
+ )}
+ />
)}
diff --git a/frontend/src/components/vault/VaultUnlock.tsx b/frontend/src/components/vault/VaultUnlock.tsx
index 454a35e9..22ef3129 100644
--- a/frontend/src/components/vault/VaultUnlock.tsx
+++ b/frontend/src/components/vault/VaultUnlock.tsx
@@ -2,8 +2,8 @@
import axios from "axios";
import { useState } from "react";
-import { api } from "@/lib/api";
import { vaultStore } from "@/store/vaultStore";
+import { unlockVault } from "./vault-client";
export function VaultUnlock() {
const [errorMessage, setErrorMessage] = useState
(null);
@@ -29,16 +29,18 @@ export function VaultUnlock() {
setIsSubmitting(true);
try {
- const response = await api.post<{ session_token: string }>(
- "/api/vault/unlock",
- {
- passphrase,
- },
- );
- vaultStore.getState().unlock(response.data.session_token);
+ const sessionToken = await unlockVault(passphrase);
+ vaultStore.getState().unlock(sessionToken);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
setErrorMessage("Incorrect vault passphrase.");
+ } else if (axios.isAxiosError(error) && error.response?.status === 400) {
+ const detail = error.response.data?.detail;
+ setErrorMessage(
+ typeof detail === "string"
+ ? detail
+ : "Use at least 8 characters for a new vault passphrase.",
+ );
} else {
setErrorMessage(
"Vault could not be unlocked right now. Please try again.",
diff --git a/frontend/src/components/vault/vault-client.ts b/frontend/src/components/vault/vault-client.ts
new file mode 100644
index 00000000..c602b4f1
--- /dev/null
+++ b/frontend/src/components/vault/vault-client.ts
@@ -0,0 +1,78 @@
+import axios from "axios";
+import { api } from "@/lib/api";
+
+export interface VaultListItem {
+ id: number;
+ filename: string;
+ content_type: string | null;
+ width: number | null;
+ height: number | null;
+ created_at: string | null;
+ hidden_at: string | null;
+}
+
+function vaultHeaders(sessionToken: string) {
+ return { Authorization: `Bearer ${sessionToken}` };
+}
+
+export async function unlockVault(passphrase: string): Promise {
+ const response = await api.post<{ session_token: string }>(
+ "/api/vault/unlock",
+ { passphrase },
+ );
+ return response.data.session_token;
+}
+
+export async function listVaultItems(
+ sessionToken: string,
+): Promise {
+ const response = await api.get("/api/vault/list", {
+ headers: vaultHeaders(sessionToken),
+ });
+ return response.data;
+}
+
+export async function lockVaultSession(sessionToken: string): Promise {
+ await api.post(
+ "/api/vault/lock",
+ {},
+ { headers: vaultHeaders(sessionToken) },
+ );
+}
+
+export async function fetchVaultThumbnail(
+ mediaId: number,
+ sessionToken: string,
+): Promise {
+ const response = await api.get(`/api/vault/thumbnail/${mediaId}`, {
+ headers: vaultHeaders(sessionToken),
+ responseType: "blob",
+ });
+ return response.data;
+}
+
+export async function fetchVaultOriginal(
+ mediaId: number,
+ sessionToken: string,
+): Promise {
+ const response = await api.get(`/api/vault/stream/${mediaId}`, {
+ headers: vaultHeaders(sessionToken),
+ responseType: "blob",
+ });
+ return response.data;
+}
+
+export async function restoreVaultItem(
+ mediaId: number,
+ sessionToken: string,
+): Promise {
+ await api.post(
+ "/api/vault/restore",
+ { media_id: mediaId },
+ { headers: vaultHeaders(sessionToken) },
+ );
+}
+
+export function isExpiredVaultSession(error: unknown): boolean {
+ return axios.isAxiosError(error) && error.response?.status === 401;
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index b415f002..31dcc7e0 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -5,6 +5,7 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
// Create axios instance with proper configuration
export const api: AxiosInstance = axios.create({
baseURL: API_URL,
+ withCredentials: true,
headers: {
"Content-Type": "application/json",
},
@@ -210,10 +211,102 @@ export interface JobStatus {
}
export interface AppConfig {
- ml_mode: "full" | "mock";
+ ml_mode: "disabled" | "full" | "mock" | "remote";
accel_mode?: AccelMode;
}
+export interface AccountUser {
+ id: number;
+ username: string;
+ display_name: string | null;
+ role: "admin" | "member";
+}
+
+export interface AuthState {
+ mode: "local" | "shared";
+ user: AccountUser | null;
+}
+
+export interface AuthStatus {
+ mode: "local" | "shared";
+ setup_available: boolean;
+}
+
+export interface AccountSession {
+ id: number;
+ created_at: string | null;
+ expires_at: string;
+ current: boolean;
+}
+
+export const getAuthStatus = async (): Promise => {
+ const response = await api.get("/api/auth/status");
+ return response.data;
+};
+
+export const getCurrentAccount = async (): Promise => {
+ const response = await api.get("/api/auth/me");
+ return response.data;
+};
+
+export const setupAccount = async (params: {
+ username: string;
+ display_name?: string;
+ password: string;
+}): Promise<{ user: AccountUser }> => {
+ const response = await api.post<{ user: AccountUser }>(
+ "/api/auth/setup",
+ params,
+ );
+ return response.data;
+};
+
+export const loginAccount = async (params: {
+ username: string;
+ password: string;
+}): Promise<{ user: AccountUser }> => {
+ const response = await api.post<{ user: AccountUser }>(
+ "/api/auth/login",
+ params,
+ );
+ return response.data;
+};
+
+export const logoutAccount = async (): Promise => {
+ await api.post("/api/auth/logout");
+};
+
+export const updateAccountProfile = async (params: {
+ username?: string;
+ display_name?: string;
+}): Promise<{ user: AccountUser }> => {
+ const response = await api.patch<{ user: AccountUser }>(
+ "/api/auth/profile",
+ params,
+ );
+ return response.data;
+};
+
+export const changeAccountPassword = async (params: {
+ current_password: string;
+ new_password: string;
+}): Promise => {
+ await api.post("/api/auth/password", params);
+};
+
+export const getAccountSessions = async (): Promise => {
+ const response = await api.get<{ sessions: AccountSession[] }>(
+ "/api/auth/sessions",
+ );
+ return response.data.sessions;
+};
+
+export const revokeAccountSession = async (
+ sessionId: number,
+): Promise => {
+ await api.delete(`/api/auth/sessions/${sessionId}`);
+};
+
export type AccelMode = "auto" | "gpu" | "cpu";
export interface HardwareCapabilities {
@@ -245,8 +338,37 @@ export const getHardwareReport = async (): Promise => {
export interface AppSettings {
accel_mode: AccelMode;
+ ai_enabled: boolean;
+ map_enabled: boolean;
+ ml_mode: "disabled" | "full" | "mock" | "remote";
+ supported_ml_modes: string[];
+}
+
+export interface RuntimeConfig {
+ build_profile: "no-ai" | "mock" | "cpu" | "nvidia" | "development" | string;
+ supported_modes: string[];
+ configured_mode: "disabled" | "mock" | "full" | "remote";
+ configured_accel_mode: AccelMode;
+ ai_enabled: boolean;
+ map_enabled: boolean;
+ applied_mode: "disabled" | "mock" | "full" | "unavailable";
+ installed_features: string[];
+ restart_required: boolean;
+ unavailable_reason: string | null;
+ worker: {
+ health: {
+ state: "healthy" | "stale" | "unknown" | "unavailable";
+ age_seconds: number | null;
+ };
+ applied: Record | null;
+ };
}
+export const getRuntimeConfig = async (): Promise => {
+ const response = await api.get("/api/config/runtime");
+ return response.data;
+};
+
export const getSettings = async (): Promise => {
const response = await api.get("/api/settings");
return response.data;
@@ -259,6 +381,49 @@ export const updateSettings = async (
return response.data;
};
+export interface MapMarker {
+ id: number;
+ lat: number;
+ lon: number;
+ filename: string;
+ created_at: string | null;
+ thumbnail_url: string;
+ ratio: number | null;
+ liked: boolean;
+}
+
+export interface MapMarkersResponse {
+ enabled: boolean;
+ markers: MapMarker[];
+ total: number;
+}
+
+export interface MapMarkerFilters {
+ includeArchived?: boolean;
+ liked?: boolean;
+ west?: number;
+ south?: number;
+ east?: number;
+ north?: number;
+}
+
+/** Load private, account-scoped photo coordinates from the local API. */
+export const getMapMarkers = async (
+ filters: MapMarkerFilters = {},
+): Promise => {
+ const response = await api.get("/api/map/markers", {
+ params: {
+ include_archived: filters.includeArchived || undefined,
+ liked: filters.liked,
+ west: filters.west,
+ south: filters.south,
+ east: filters.east,
+ north: filters.north,
+ },
+ });
+ return response.data;
+};
+
// API Functions
export const uploadImages = async (
files: FileList | File[],
diff --git a/frontend/src/lib/media-timeline.ts b/frontend/src/lib/media-timeline.ts
new file mode 100644
index 00000000..a9869d57
--- /dev/null
+++ b/frontend/src/lib/media-timeline.ts
@@ -0,0 +1,234 @@
+/**
+ * Pure helpers for route-agnostic media timelines.
+ *
+ * The grouping and scroll-mapping behavior is adapted from the AGPL-3.0
+ * reference project's month timeline. Original copyright belongs to its
+ * authors. This file is part of Find and is distributed under AGPL-3.0.
+ * See NOTICE.
+ */
+
+import type { ScrubberLayout, ScrubberSegment } from "@/lib/timeline-scrubber";
+
+export type MediaTimelineOrder = "newest" | "oldest";
+
+export interface MediaTimelineGroup {
+ /** `YYYY-MM-01` for dated media, or `undated` when no valid date exists. */
+ timeBucket: string;
+ label: string;
+ items: T[];
+}
+
+export interface TimelineSectionMeasurement {
+ timeBucket: string;
+ /** Section top relative to the timeline root, in real rendered pixels. */
+ top: number;
+ /** Section height in real rendered pixels. */
+ height: number;
+}
+
+const MONTH_FORMATTER = new Intl.DateTimeFormat("en", {
+ month: "long",
+ timeZone: "UTC",
+ year: "numeric",
+});
+
+function validDate(value: string | null | undefined): Date | null {
+ if (!value) {
+ return null;
+ }
+
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+export function mediaMonthBucket(
+ value: string | null | undefined,
+): string | null {
+ const date = validDate(value);
+ if (!date) {
+ return null;
+ }
+
+ const year = date.getUTCFullYear();
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
+ return `${year}-${month}-01`;
+}
+
+export function mediaMonthLabel(timeBucket: string): string {
+ if (timeBucket === "undated") {
+ return "Undated";
+ }
+
+ const match = /^(\d{4})-(\d{2})-01$/.exec(timeBucket);
+ if (!match) {
+ return timeBucket;
+ }
+
+ const year = Number.parseInt(match[1] ?? "", 10);
+ const month = Number.parseInt(match[2] ?? "", 10);
+ if (!Number.isFinite(year) || month < 1 || month > 12) {
+ return timeBucket;
+ }
+
+ return MONTH_FORMATTER.format(new Date(Date.UTC(year, month - 1, 1)));
+}
+
+/**
+ * Group arbitrary route media by month and sort both groups and assets by date.
+ * Invalid or missing dates are retained in a final `Undated` section.
+ */
+export function groupMediaByMonth(
+ items: readonly T[],
+ getDate: (item: T) => string | null | undefined,
+ order: MediaTimelineOrder = "newest",
+): MediaTimelineGroup[] {
+ const dated = new Map>();
+ const undated: T[] = [];
+
+ for (const item of items) {
+ const value = getDate(item);
+ const date = validDate(value);
+ const timeBucket = mediaMonthBucket(value);
+ if (!date || !timeBucket) {
+ undated.push(item);
+ continue;
+ }
+
+ const group = dated.get(timeBucket) ?? [];
+ group.push({ item, timestamp: date.getTime() });
+ dated.set(timeBucket, group);
+ }
+
+ const direction = order === "newest" ? -1 : 1;
+ const groups = [...dated.entries()]
+ .sort(([left], [right]) => left.localeCompare(right) * direction)
+ .map(([timeBucket, group]) => ({
+ timeBucket,
+ label: mediaMonthLabel(timeBucket),
+ items: group
+ .sort((left, right) => (left.timestamp - right.timestamp) * direction)
+ .map(({ item }) => item),
+ }));
+
+ if (undated.length > 0) {
+ groups.push({ timeBucket: "undated", label: "Undated", items: undated });
+ }
+
+ return groups;
+}
+
+export function timelineBucketsFromGroups(
+ groups: readonly MediaTimelineGroup[],
+): Array<{ timeBucket: string; count: number }> {
+ return groups.map((group) => ({
+ timeBucket: group.timeBucket,
+ count: group.items.length,
+ }));
+}
+
+export function mediaAspectRatio(
+ width: number | null | undefined,
+ height: number | null | undefined,
+): number {
+ if (
+ typeof width !== "number" ||
+ typeof height !== "number" ||
+ !Number.isFinite(width) ||
+ !Number.isFinite(height) ||
+ width <= 0 ||
+ height <= 0
+ ) {
+ return 1;
+ }
+
+ return width / height;
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+function segmentForBucket(
+ layout: ScrubberLayout,
+ timeBucket: string,
+): ScrubberSegment | undefined {
+ return layout.segments.find((segment) => segment.timeBucket === timeBucket);
+}
+
+/**
+ * Translate real DOM scroll pixels into the scrubber's estimated-height space.
+ * Preserving within-section progress keeps the thumb synchronized even though
+ * justified rows make each rendered month a different height than its estimate.
+ */
+export function actualOffsetToScrubberOffset(
+ measurements: readonly TimelineSectionMeasurement[],
+ layout: ScrubberLayout,
+ actualOffset: number,
+): number {
+ if (measurements.length === 0 || layout.segments.length === 0) {
+ return 0;
+ }
+
+ const ordered = [...measurements].sort((left, right) => left.top - right.top);
+ const clampedOffset = Math.max(0, actualOffset);
+ let active = ordered[0];
+
+ for (const measurement of ordered) {
+ if (measurement.top > clampedOffset) {
+ break;
+ }
+ active = measurement;
+ }
+
+ if (!active) {
+ return 0;
+ }
+
+ const segment = segmentForBucket(layout, active.timeBucket);
+ if (!segment) {
+ return 0;
+ }
+
+ const progress =
+ active.height > 0
+ ? clamp((clampedOffset - active.top) / active.height, 0, 1)
+ : 0;
+ return segment.offsetTop + progress * segment.height;
+}
+
+/** Reverse `actualOffsetToScrubberOffset` for a scrubber-driven window jump. */
+export function scrubberOffsetToActualOffset(
+ measurements: readonly TimelineSectionMeasurement[],
+ layout: ScrubberLayout,
+ scrubberOffset: number,
+): number {
+ if (measurements.length === 0 || layout.segments.length === 0) {
+ return 0;
+ }
+
+ const clampedOffset = clamp(scrubberOffset, 0, layout.totalHeight);
+ let segment = layout.segments[0];
+ for (const candidate of layout.segments) {
+ if (candidate.offsetTop > clampedOffset) {
+ break;
+ }
+ segment = candidate;
+ }
+
+ if (!segment) {
+ return 0;
+ }
+
+ const measurement = measurements.find(
+ (candidate) => candidate.timeBucket === segment.timeBucket,
+ );
+ if (!measurement) {
+ return 0;
+ }
+
+ const progress =
+ segment.height > 0
+ ? clamp((clampedOffset - segment.offsetTop) / segment.height, 0, 1)
+ : 0;
+ return measurement.top + progress * measurement.height;
+}
diff --git a/frontend/src/lib/viewer-preload.ts b/frontend/src/lib/viewer-preload.ts
index 4fa06fd1..b2be8ed6 100644
--- a/frontend/src/lib/viewer-preload.ts
+++ b/frontend/src/lib/viewer-preload.ts
@@ -17,6 +17,8 @@ export type LoadQuality = "thumbnail" | "original";
export interface ViewerAsset {
id: number;
thumbnailUrl: string;
+ /** Context-safe accessible description for the displayed image. */
+ alt?: string;
/** Original/full-res URL; may be null until resolved. */
originalUrl?: string | null;
}
From fba69cd37dd366e05baa8e093c983bef5a123db8 Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Mon, 13 Jul 2026 02:11:00 +0530
Subject: [PATCH 03/21] build: split modular runtime artifacts
---
.dockerignore | 4 +
.env.example | 22 +++-
backend/Dockerfile | 18 ++-
compose.base.yml | 142 ++++++++++++++++++++++
compose.cpu.yml | 57 +++++++++
compose.mock.yml | 52 ++++++++
compose.no-ai.yml | 30 +++++
compose.yml | 77 ++++++++++++
docker-compose.light.yml | 149 -----------------------
docker-compose.yml | 179 ----------------------------
frontend/package.json | 3 +-
frontend/pnpm-lock.yaml | 184 +++++++++++++++++++++++++++++
frontend/src-tauri/Cargo.lock | 2 +-
frontend/src-tauri/Cargo.toml | 2 +-
frontend/src-tauri/tauri.conf.json | 2 +-
15 files changed, 575 insertions(+), 348 deletions(-)
create mode 100644 compose.base.yml
create mode 100644 compose.cpu.yml
create mode 100644 compose.mock.yml
create mode 100644 compose.no-ai.yml
create mode 100644 compose.yml
delete mode 100644 docker-compose.light.yml
delete mode 100644 docker-compose.yml
diff --git a/.dockerignore b/.dockerignore
index 4b79e62b..6510d19f 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -24,6 +24,10 @@ backend/vault_storage
**/*.sqlite3
.env
+# Read-only local comparison checkout. It must never enter a build context.
+reference-app
+@reference-app
+
# Logs and temporary files
**/*.log
**/*.tmp
diff --git a/.env.example b/.env.example
index 4137682c..43a8aa58 100644
--- a/.env.example
+++ b/.env.example
@@ -6,7 +6,7 @@
# REDIS_PASSWORD, MINIO_ROOT_USER/PASSWORD, and the matching *_URL strings).
# The API refuses to start in production while the default db/object-store
# credentials are still in place. Datastore ports are bound to 127.0.0.1 in
-# docker-compose, so the datastores are not reachable from the network.
+# Compose, so the datastores are not reachable from the network.
# ──────────────────────────────────────────────────────────────────────────
# Database
@@ -66,10 +66,19 @@ NEXT_PUBLIC_MAX_BULK_FILES=200
NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB=50
# ML Model Settings
+# Normally set by the selected compose file: no-ai | mock | cpu | nvidia.
+# It describes packages baked into the image; changing it does not install AI.
+FIND_BUILD_PROFILE=nvidia
+# disabled | mock | full | remote. The selected artifact must support the mode.
ML_MODE=full
-# Remote ML Acceleration
-# Disabled by default. Only used when ML_MODE=remote.
-# REMOTE_ML_URL must point to a self-hosted Find ML server.
+# Instance-wide AI kill switch. The dashboard persists an override in the DB,
+# and workers read it at the start of every job.
+AI_ENABLED=true
+# Opt in to retaining GPS coordinates from EXIF for the private map. Workers
+# also read this at each job boundary. False drops/clears stored coordinates.
+MAP_ENABLED=false
+# Remote ML/BYOK fields are reserved for a future self-hosted adapter. Today,
+# ML_MODE=remote is reported as unavailable and never falls back to local AI.
REMOTE_ML_URL=
REMOTE_ML_API_KEY=
REMOTE_ML_STRIP_EXIF=true
@@ -104,9 +113,10 @@ YOLO_HALF=true
# auto = use the best available accelerator, else CPU (default)
# gpu = prefer GPU; automatically fall back to CPU if unavailable
# cpu = force CPU (works on any machine)
-# See docs/guides/hardware-acceleration.md. Legacy USE_GPU=false still pins CPU
-# when ACCEL_MODE is left at auto.
+# See docs/guides/hardware-acceleration.md. A persisted dashboard value wins
+# at request/job boundaries. Legacy USE_GPU=false still pins auto mode to CPU.
ACCEL_MODE=auto
+# Used only by the default NVIDIA compose.yml profile.
BACKEND_BASE_IMAGE=nvidia/cuda:12.1.1-runtime-ubuntu22.04
BACKEND_PYTHON_VERSION=3.12
EMBEDDING_DIM=768
diff --git a/backend/Dockerfile b/backend/Dockerfile
index b1b9eaa5..14e59c3d 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -2,26 +2,23 @@
ARG BASE_IMAGE=python:3.12-slim
ARG PYTHON_VERSION=3.12
+ARG FIND_BUILD_PROFILE=no-ai
ARG UV_SYNC_EXTRAS=""
FROM ${BASE_IMAGE}
ARG PYTHON_VERSION=3.12
+ARG FIND_BUILD_PROFILE=no-ai
ARG UV_SYNC_EXTRAS=""
# Install system dependencies.
#
-# The set we need depends entirely on whether the ML extra is enabled:
-# * ML build (UV_SYNC_EXTRAS contains "ml") — needs a C toolchain to compile
-# hdbscan and the OpenCV/X11 runtime libs for opencv + torch vision ops, plus
-# a system python because the CUDA base image ships none.
-# * Light build (UV_SYNC_EXTRAS empty) — runs on python:3.12-slim, which already
-# has Python, and every base dependency installs from a prebuilt wheel. No
-# compiler, no OpenCV libs. Installing them anyway pulled the whole gcc-14
-# toolchain (~30 min apt) into an image that never uses it.
+# The selected build profile controls this layer. CPU and NVIDIA artifacts need
+# native build/OpenCV runtime libraries; no-AI and mock artifacts intentionally
+# stay on the minimal Python base and never install those dependencies.
#
# So we branch: full set for ML, just curl (needed to fetch uv) for light.
RUN set -eux; \
apt-get update; \
- if echo "${UV_SYNC_EXTRAS}" | grep -q "ml"; then \
+ if [ "${FIND_BUILD_PROFILE}" = "cpu" ] || [ "${FIND_BUILD_PROFILE}" = "nvidia" ]; then \
apt-get install -y --no-install-recommends \
python3 python3-dev build-essential libpq-dev \
libgl1 libglib2.0-0 libsm6 libxext6 libxrender-dev libgomp1 curl; \
@@ -45,6 +42,7 @@ ENV UV_PYTHON="${PYTHON_VERSION}"
ENV UV_LINK_MODE="copy"
ENV PATH="/opt/find/.venv/bin:/usr/local/bin:/root/.local/bin:/root/.cargo/bin:$PATH"
ENV PYTHONPATH="/app/src"
+ENV FIND_BUILD_PROFILE="${FIND_BUILD_PROFILE}"
# Set working directory
WORKDIR /app
@@ -55,7 +53,7 @@ COPY pyproject.toml uv.lock ./
# Install Python dependencies with uv. Keep uv's package cache in a BuildKit
# cache mount so it speeds rebuilds without bloating the final runtime image.
RUN --mount=type=cache,target=/root/.cache/uv \
- uv sync --no-dev --no-install-project ${UV_SYNC_EXTRAS}
+ uv sync --locked --no-dev --no-install-project ${UV_SYNC_EXTRAS}
# Copy application code
COPY . .
diff --git a/compose.base.yml b/compose.base.yml
new file mode 100644
index 00000000..136ad5ec
--- /dev/null
+++ b/compose.base.yml
@@ -0,0 +1,142 @@
+services:
+ db:
+ image: pgvector/pgvector:0.8.4-pg16-bookworm
+ environment:
+ POSTGRES_DB: find
+ POSTGRES_USER: find
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ ports:
+ - "127.0.0.1:5432:5432"
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U find"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ redis:
+ image: redis:7.4.9-alpine3.21
+ command: redis-server --loglevel warning --requirepass ${REDIS_PASSWORD}
+ hostname: redis
+ ports:
+ - "127.0.0.1:6379:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
+ interval: 10s
+ timeout: 3s
+ retries: 5
+
+ minio:
+ image: minio/minio:RELEASE.2025-09-07T16-13-09Z
+ command: server /data --console-address ":9001"
+ environment:
+ MINIO_ROOT_USER: ${MINIO_ROOT_USER}
+ MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
+ ports:
+ - "127.0.0.1:${MINIO_HOST_PORT:-9200}:9000"
+ - "127.0.0.1:${MINIO_CONSOLE_HOST_PORT:-9201}:9001"
+ volumes:
+ - minio_data:/data
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
+ interval: 30s
+ timeout: 20s
+ retries: 3
+
+ api:
+ image: find-backend:no-ai
+ pull_policy: never
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ args:
+ BASE_IMAGE: python:3.12-slim
+ PYTHON_VERSION: "3.12"
+ FIND_BUILD_PROFILE: no-ai
+ UV_SYNC_EXTRAS: ""
+ command: uvicorn find_api.main:app --host 0.0.0.0 --port 8000 --log-level warning
+ ports:
+ - "8000:8000"
+ environment: &backend-environment
+ DATABASE_URL: ${DATABASE_URL}
+ MINIO_ENDPOINT: ${MINIO_ENDPOINT:-minio:9000}
+ MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-http://localhost:9200/images}
+ MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
+ MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
+ MINIO_BUCKET: ${MINIO_BUCKET:-images}
+ MINIO_PUBLIC_READ: ${MINIO_PUBLIC_READ:-false}
+ MINIO_SECURE: ${MINIO_SECURE:-false}
+ REDIS_URL: ${REDIS_URL}
+ FIND_BUILD_PROFILE: no-ai
+ ML_MODE: disabled
+ AI_ENABLED: "false"
+ MAP_ENABLED: ${MAP_ENABLED:-false}
+ ACCEL_MODE: cpu
+ USE_GPU: "false"
+ API_HOST: 0.0.0.0
+ API_PORT: "8000"
+ MODEL_MANAGER_PROCESS_NAME: api
+ PYTHONUNBUFFERED: "1"
+ volumes:
+ - ./backend:/app
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ minio:
+ condition: service_healthy
+ restart: unless-stopped
+
+ worker:
+ image: find-backend:no-ai
+ pull_policy: never
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ args:
+ BASE_IMAGE: python:3.12-slim
+ PYTHON_VERSION: "3.12"
+ FIND_BUILD_PROFILE: no-ai
+ UV_SYNC_EXTRAS: ""
+ command: rq worker --worker-class ${RQ_WORKER_CLASS:-rq.worker.worker_classes.SimpleWorker} --url ${REDIS_URL:-redis://:find-redis-dev@redis:6379} high default low
+ environment:
+ <<: *backend-environment
+ MODEL_MANAGER_PROCESS_NAME: worker
+ volumes:
+ - ./backend:/app
+ depends_on:
+ api:
+ condition: service_started
+ redis:
+ condition: service_healthy
+ db:
+ condition: service_healthy
+ restart: unless-stopped
+
+ web:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ target: production
+ ports:
+ - "3000:3000"
+ environment:
+ NEXT_PUBLIC_API_URL: http://localhost:8000
+ NEXT_PUBLIC_MINIO_BUCKET: ${NEXT_PUBLIC_MINIO_BUCKET:-images}
+ NEXT_PUBLIC_MINIO_URL: ${NEXT_PUBLIC_MINIO_URL:-http://localhost:9200}
+ NEXT_PUBLIC_MAX_BULK_FILES: ${NEXT_PUBLIC_MAX_BULK_FILES:-200}
+ NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB: ${NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB:-50}
+ NODE_ENV: production
+ depends_on:
+ api:
+ condition: service_started
+ restart: unless-stopped
+
+volumes:
+ db_data:
+ redis_data:
+ minio_data:
diff --git a/compose.cpu.yml b/compose.cpu.yml
new file mode 100644
index 00000000..1c8ba1d9
--- /dev/null
+++ b/compose.cpu.yml
@@ -0,0 +1,57 @@
+services:
+ db:
+ extends:
+ file: compose.base.yml
+ service: db
+ redis:
+ extends:
+ file: compose.base.yml
+ service: redis
+ minio:
+ extends:
+ file: compose.base.yml
+ service: minio
+ api:
+ extends:
+ file: compose.base.yml
+ service: api
+ image: find-backend:cpu
+ build:
+ args:
+ FIND_BUILD_PROFILE: cpu
+ UV_SYNC_EXTRAS: "--extra cpu"
+ environment:
+ FIND_BUILD_PROFILE: cpu
+ ML_MODE: full
+ AI_ENABLED: "${AI_ENABLED:-true}"
+ ACCEL_MODE: "${ACCEL_MODE:-cpu}"
+ USE_GPU: "false"
+ volumes:
+ - model_cache:/root/.cache
+ worker:
+ extends:
+ file: compose.base.yml
+ service: worker
+ image: find-backend:cpu
+ build:
+ args:
+ FIND_BUILD_PROFILE: cpu
+ UV_SYNC_EXTRAS: "--extra cpu"
+ environment:
+ FIND_BUILD_PROFILE: cpu
+ ML_MODE: full
+ AI_ENABLED: "${AI_ENABLED:-true}"
+ ACCEL_MODE: "${ACCEL_MODE:-cpu}"
+ USE_GPU: "false"
+ volumes:
+ - model_cache:/root/.cache
+ web:
+ extends:
+ file: compose.base.yml
+ service: web
+
+volumes:
+ db_data:
+ redis_data:
+ minio_data:
+ model_cache:
diff --git a/compose.mock.yml b/compose.mock.yml
new file mode 100644
index 00000000..bd1c0a7c
--- /dev/null
+++ b/compose.mock.yml
@@ -0,0 +1,52 @@
+services:
+ db:
+ extends:
+ file: compose.base.yml
+ service: db
+ redis:
+ extends:
+ file: compose.base.yml
+ service: redis
+ minio:
+ extends:
+ file: compose.base.yml
+ service: minio
+ api:
+ extends:
+ file: compose.base.yml
+ service: api
+ image: find-backend:mock
+ build:
+ args:
+ FIND_BUILD_PROFILE: mock
+ UV_SYNC_EXTRAS: "--extra mock"
+ environment:
+ FIND_BUILD_PROFILE: mock
+ ML_MODE: mock
+ AI_ENABLED: "${AI_ENABLED:-true}"
+ ACCEL_MODE: cpu
+ USE_GPU: "false"
+ worker:
+ extends:
+ file: compose.base.yml
+ service: worker
+ image: find-backend:mock
+ build:
+ args:
+ FIND_BUILD_PROFILE: mock
+ UV_SYNC_EXTRAS: "--extra mock"
+ environment:
+ FIND_BUILD_PROFILE: mock
+ ML_MODE: mock
+ AI_ENABLED: "${AI_ENABLED:-true}"
+ ACCEL_MODE: cpu
+ USE_GPU: "false"
+ web:
+ extends:
+ file: compose.base.yml
+ service: web
+
+volumes:
+ db_data:
+ redis_data:
+ minio_data:
diff --git a/compose.no-ai.yml b/compose.no-ai.yml
new file mode 100644
index 00000000..dd80faee
--- /dev/null
+++ b/compose.no-ai.yml
@@ -0,0 +1,30 @@
+services:
+ db:
+ extends:
+ file: compose.base.yml
+ service: db
+ redis:
+ extends:
+ file: compose.base.yml
+ service: redis
+ minio:
+ extends:
+ file: compose.base.yml
+ service: minio
+ api:
+ extends:
+ file: compose.base.yml
+ service: api
+ worker:
+ extends:
+ file: compose.base.yml
+ service: worker
+ web:
+ extends:
+ file: compose.base.yml
+ service: web
+
+volumes:
+ db_data:
+ redis_data:
+ minio_data:
diff --git a/compose.yml b/compose.yml
new file mode 100644
index 00000000..b2c97bbf
--- /dev/null
+++ b/compose.yml
@@ -0,0 +1,77 @@
+services:
+ db:
+ extends:
+ file: compose.base.yml
+ service: db
+ redis:
+ extends:
+ file: compose.base.yml
+ service: redis
+ minio:
+ extends:
+ file: compose.base.yml
+ service: minio
+ api:
+ extends:
+ file: compose.base.yml
+ service: api
+ image: find-backend:nvidia
+ build:
+ args:
+ BASE_IMAGE: "${BACKEND_BASE_IMAGE:-nvidia/cuda:12.1.1-runtime-ubuntu22.04}"
+ FIND_BUILD_PROFILE: nvidia
+ UV_SYNC_EXTRAS: "--extra nvidia"
+ environment:
+ FIND_BUILD_PROFILE: nvidia
+ ML_MODE: full
+ AI_ENABLED: "${AI_ENABLED:-true}"
+ ACCEL_MODE: "${ACCEL_MODE:-auto}"
+ USE_GPU: "true"
+ volumes:
+ - model_cache:/root/.cache
+ gpus: all
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: 1
+ capabilities: [gpu]
+ worker:
+ extends:
+ file: compose.base.yml
+ service: worker
+ image: find-backend:nvidia
+ build:
+ args:
+ BASE_IMAGE: "${BACKEND_BASE_IMAGE:-nvidia/cuda:12.1.1-runtime-ubuntu22.04}"
+ FIND_BUILD_PROFILE: nvidia
+ UV_SYNC_EXTRAS: "--extra nvidia"
+ environment:
+ FIND_BUILD_PROFILE: nvidia
+ ML_MODE: full
+ AI_ENABLED: "${AI_ENABLED:-true}"
+ ACCEL_MODE: "${ACCEL_MODE:-auto}"
+ USE_GPU: "true"
+ volumes:
+ - model_cache:/root/.cache
+ gpus: all
+ deploy:
+ resources:
+ limits:
+ memory: ${WORKER_MEMORY_LIMIT:-8G}
+ reservations:
+ devices:
+ - driver: nvidia
+ count: 1
+ capabilities: [gpu]
+ web:
+ extends:
+ file: compose.base.yml
+ service: web
+
+volumes:
+ db_data:
+ redis_data:
+ minio_data:
+ model_cache:
diff --git a/docker-compose.light.yml b/docker-compose.light.yml
deleted file mode 100644
index 84cb11a5..00000000
--- a/docker-compose.light.yml
+++ /dev/null
@@ -1,149 +0,0 @@
-x-light-backend: &light-backend
- image: find-light-backend
- pull_policy: never
- build:
- context: ./backend
- dockerfile: Dockerfile
- args:
- BASE_IMAGE: python:3.12-slim
- PYTHON_VERSION: "3.12"
- UV_SYNC_EXTRAS: ""
-
-services:
- db:
- image: pgvector/pgvector:0.8.4-pg16-bookworm
- container_name: find-light-db
- # Local-only defaults for contributor setup. Override via .env for anything else.
- environment:
- POSTGRES_DB: find
- POSTGRES_USER: find
- POSTGRES_PASSWORD: find123
- ports:
- # Loopback only — contributor setup is single-machine.
- - "127.0.0.1:5432:5432"
- volumes:
- - light_db_data:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U find"]
- interval: 10s
- timeout: 5s
- retries: 5
-
- redis:
- image: redis:7.4.9-alpine3.21
- command: redis-server --loglevel warning
- container_name: find-light-redis
- ports:
- - "127.0.0.1:6379:6379"
- volumes:
- - light_redis_data:/data
- healthcheck:
- test: ["CMD", "redis-cli", "ping"]
- interval: 10s
- timeout: 3s
- retries: 5
-
- minio:
- image: minio/minio:RELEASE.2025-09-07T16-13-09Z
- container_name: find-light-minio
- command: server /data --console-address ":9001"
- # Local-only defaults for contributor setup. Never reuse these credentials in production.
- environment:
- MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
- ports:
- - "127.0.0.1:${MINIO_HOST_PORT:-9200}:9000"
- - "127.0.0.1:${MINIO_CONSOLE_HOST_PORT:-9201}:9001"
- volumes:
- - light_minio_data:/data
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
- interval: 30s
- timeout: 20s
- retries: 3
-
- api:
- <<: *light-backend
- container_name: find-light-api
- command: uvicorn find_api.main:app --host 0.0.0.0 --port 8000 --log-level warning
- ports:
- - "8000:8000"
- environment:
- - DATABASE_URL=postgresql://find:find123@db:5432/find
- - MINIO_ENDPOINT=minio:9000
- - MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-http://localhost:9200/images}
- - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
- - MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
- - MINIO_BUCKET=${MINIO_BUCKET:-images}
- - MINIO_PUBLIC_READ=false
- - MINIO_SECURE=false
- - REDIS_URL=redis://redis:6379
- - ML_MODE=mock
- - USE_GPU=false
- - API_HOST=0.0.0.0
- - API_PORT=8000
- - MODEL_MANAGER_PROCESS_NAME=api
- - PYTHONUNBUFFERED=1
- volumes:
- - ./backend:/app
- depends_on:
- db:
- condition: service_healthy
- redis:
- condition: service_healthy
- minio:
- condition: service_healthy
- restart: unless-stopped
-
- worker:
- <<: *light-backend
- container_name: find-light-worker
- command: rq worker --worker-class ${RQ_WORKER_CLASS:-rq.worker.worker_classes.SimpleWorker} --url redis://redis:6379 high default low
- environment:
- - DATABASE_URL=postgresql://find:find123@db:5432/find
- - MINIO_ENDPOINT=minio:9000
- - MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-http://localhost:9200/images}
- - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
- - MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
- - MINIO_BUCKET=${MINIO_BUCKET:-images}
- - MINIO_PUBLIC_READ=false
- - MINIO_SECURE=false
- - REDIS_URL=redis://redis:6379
- - ML_MODE=mock
- - USE_GPU=false
- - MODEL_MANAGER_PROCESS_NAME=worker
- - PYTHONUNBUFFERED=1
- volumes:
- - ./backend:/app
- depends_on:
- - api
- - redis
- - db
- restart: unless-stopped
-
- web:
- build:
- context: ./frontend
- dockerfile: Dockerfile
- target: production
- container_name: find-light-web
- ports:
- - "3000:3000"
- environment:
- - NEXT_PUBLIC_API_URL=http://localhost:8000
- - NEXT_PUBLIC_MINIO_BUCKET=${NEXT_PUBLIC_MINIO_BUCKET:-images}
- - NEXT_PUBLIC_MINIO_URL=${NEXT_PUBLIC_MINIO_URL:-http://localhost:9200}
- - NEXT_PUBLIC_MAX_BULK_FILES=${NEXT_PUBLIC_MAX_BULK_FILES:-200}
- - NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB=${NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB:-50}
- - NODE_ENV=production
- depends_on:
- - api
- restart: unless-stopped
-
-volumes:
- light_db_data:
- driver: local
- light_redis_data:
- driver: local
- light_minio_data:
- driver: local
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 0f4a70f5..00000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,179 +0,0 @@
-x-full-backend: &full-backend
- image: find-backend
- pull_policy: never
- build:
- context: ./backend
- dockerfile: Dockerfile
- args:
- BASE_IMAGE: ${BACKEND_BASE_IMAGE:-nvidia/cuda:12.1.1-runtime-ubuntu22.04}
- PYTHON_VERSION: ${BACKEND_PYTHON_VERSION:-3.12}
- UV_SYNC_EXTRAS: "--extra ml"
-
-services:
- # PostgreSQL Database with pgvector
- db:
- image: pgvector/pgvector:0.8.4-pg16-bookworm
- container_name: find-db
- environment:
- POSTGRES_DB: find
- POSTGRES_USER: find
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-find123}
- ports:
- # Bound to loopback only: other services reach the db over the compose
- # network. Not network-exposed, so the default password is not remotely
- # reachable. Override the bind address for remote admin access.
- - "127.0.0.1:5432:5432"
- volumes:
- - db_data:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U find"]
- interval: 10s
- timeout: 5s
- retries: 5
-
- # Redis for job queue
- redis:
- image: redis:7.4.9-alpine3.21
- command: redis-server --loglevel warning --requirepass ${REDIS_PASSWORD:-find-redis-dev}
- container_name: find-redis
- hostname: redis
- ports:
- # Loopback only — the queue is internal to the compose network.
- - "127.0.0.1:6379:6379"
- volumes:
- - redis_data:/data
- healthcheck:
- test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-find-redis-dev}", "ping"]
- interval: 10s
- timeout: 3s
- retries: 5
-
- # MinIO for object storage
- minio:
- image: minio/minio:RELEASE.2025-09-07T16-13-09Z
- container_name: find-minio
- command: server /data --console-address ":9001"
- environment:
- MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
- ports:
- # Loopback only by default. Browser access to images goes through the
- # API/next image proxy, not directly to MinIO.
- - "127.0.0.1:${MINIO_HOST_PORT:-9200}:9000"
- - "127.0.0.1:${MINIO_CONSOLE_HOST_PORT:-9201}:9001"
- volumes:
- - minio_data:/data
- healthcheck:
- test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
- interval: 30s
- timeout: 20s
- retries: 3
-
- # FastAPI Backend
- api:
- <<: *full-backend
- container_name: find-api
- command: uvicorn find_api.main:app --host 0.0.0.0 --port 8000 --log-level warning
- ports:
- - "8000:8000"
- environment:
- - DATABASE_URL=${DATABASE_URL:-postgresql://find:find123@db:5432/find}
- - MINIO_ENDPOINT=${MINIO_ENDPOINT:-minio:9000}
- - MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-http://localhost:9200/images}
- - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
- - MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
- - MINIO_BUCKET=${MINIO_BUCKET:-images}
- - MINIO_PUBLIC_READ=${MINIO_PUBLIC_READ:-false}
- - MINIO_SECURE=${MINIO_SECURE:-false}
- - REDIS_URL=${REDIS_URL:-redis://:find-redis-dev@redis:6379}
- - USE_GPU=${USE_GPU:-true}
- - API_HOST=0.0.0.0
- - API_PORT=8000
- - MODEL_MANAGER_PROCESS_NAME=api
- - PYTHONUNBUFFERED=1
- volumes:
- - ./backend:/app
- - model_cache:/root/.cache
- gpus: all
- deploy:
- resources:
- reservations:
- devices:
- - driver: nvidia
- count: 1
- capabilities: [gpu]
- depends_on:
- db:
- condition: service_healthy
- redis:
- condition: service_healthy
- minio:
- condition: service_healthy
- restart: unless-stopped
-
- # RQ Worker for background ML processing
- worker:
- <<: *full-backend
- container_name: find-worker
- command: rq worker --worker-class ${RQ_WORKER_CLASS:-rq.worker.worker_classes.SimpleWorker} --url ${REDIS_URL:-redis://:find-redis-dev@redis:6379} high default low
- environment:
- - DATABASE_URL=${DATABASE_URL:-postgresql://find:find123@db:5432/find}
- - MINIO_ENDPOINT=${MINIO_ENDPOINT:-minio:9000}
- - MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-http://localhost:9200/images}
- - MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
- - MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
- - MINIO_BUCKET=${MINIO_BUCKET:-images}
- - MINIO_PUBLIC_READ=${MINIO_PUBLIC_READ:-false}
- - MINIO_SECURE=${MINIO_SECURE:-false}
- - REDIS_URL=${REDIS_URL:-redis://:find-redis-dev@redis:6379}
- - USE_GPU=${USE_GPU:-true}
- - MODEL_MANAGER_PROCESS_NAME=worker
- - PYTHONUNBUFFERED=1
- volumes:
- - ./backend:/app
- - model_cache:/root/.cache
- depends_on:
- - api
- - redis
- - db
- restart: unless-stopped
- gpus: all
- deploy:
- resources:
- limits:
- memory: 8G
- reservations:
- devices:
- - driver: nvidia
- count: 1
- capabilities: [gpu]
-
- # Next.js Frontend
- web:
- build:
- context: ./frontend
- dockerfile: Dockerfile
- target: production
- container_name: find-web
- ports:
- - "3000:3000"
- environment:
- - NEXT_PUBLIC_API_URL=http://localhost:8000
- - NEXT_PUBLIC_MINIO_BUCKET=${NEXT_PUBLIC_MINIO_BUCKET:-images}
- - NEXT_PUBLIC_MINIO_URL=${NEXT_PUBLIC_MINIO_URL:-http://localhost:9200}
- - NEXT_PUBLIC_MAX_BULK_FILES=${NEXT_PUBLIC_MAX_BULK_FILES:-200}
- - NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB=${NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB:-50}
- - NODE_ENV=production
- depends_on:
- - api
- restart: unless-stopped
-
-volumes:
- db_data:
- driver: local
- redis_data:
- driver: local
- minio_data:
- driver: local
- model_cache:
- driver: local
diff --git a/frontend/package.json b/frontend/package.json
index 569cec30..9555b7d5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "find-frontend",
- "version": "1.0.0",
+ "version": "1.1.0",
"packageManager": "pnpm@11.3.0",
"private": true,
"license": "AGPL-3.0-only",
@@ -25,6 +25,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^1.14.0",
+ "maplibre-gl": "^5.24.0",
"next": "^16.2.6",
"react": "^19.2.5",
"react-dom": "^19.2.5",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index c466e4b4..4e2f4766 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -32,6 +32,9 @@ importers:
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.5)
+ maplibre-gl:
+ specifier: ^5.24.0
+ version: 5.24.0
next:
specifier: ^16.2.6
version: 16.2.6(@playwright/test@1.61.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -430,6 +433,42 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@mapbox/jsonlint-lines-primitives@2.0.3':
+ resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==}
+ engines: {node: '>= 22'}
+
+ '@mapbox/point-geometry@1.1.0':
+ resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
+
+ '@mapbox/tiny-sdf@2.2.0':
+ resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
+
+ '@mapbox/unitbezier@0.0.1':
+ resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==}
+
+ '@mapbox/unitbezier@1.0.0':
+ resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==}
+
+ '@mapbox/vector-tile@2.0.5':
+ resolution: {integrity: sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==}
+
+ '@mapbox/whoots-js@3.1.0':
+ resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==}
+ engines: {node: '>=6.0.0'}
+
+ '@maplibre/geojson-vt@6.1.1':
+ resolution: {integrity: sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==}
+
+ '@maplibre/maplibre-gl-style-spec@24.10.0':
+ resolution: {integrity: sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==}
+ hasBin: true
+
+ '@maplibre/mlt@1.1.12':
+ resolution: {integrity: sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==}
+
+ '@maplibre/vt-pbf@4.3.2':
+ resolution: {integrity: sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==}
+
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@@ -737,6 +776,9 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
'@types/node@22.19.1':
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
@@ -962,6 +1004,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ earcut@3.2.3:
+ resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
+
electron-to-chromium@1.5.348:
resolution: {integrity: sha512-QC2X59nRlycQQMc4ZXjSVBX+tSgJfgRtcrYHbIZLgOV2dCvefoQGegLR7lLXKgpPpSuVmJU19LMzGrSa2C7k3Q==}
@@ -1060,6 +1105,9 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ gl-matrix@3.4.4:
+ resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
+
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1134,6 +1182,12 @@ packages:
canvas:
optional: true
+ json-stringify-pretty-compact@4.0.0:
+ resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
+
+ kdbush@4.1.0:
+ resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
+
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -1235,6 +1289,10 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ maplibre-gl@5.24.0:
+ resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==}
+ engines: {node: '>=16.14.0', npm: '>=8.1.0'}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1262,6 +1320,12 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ murmurhash-js@1.0.0:
+ resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
+
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -1322,6 +1386,14 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ pbf@4.0.2:
+ resolution: {integrity: sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==}
+ hasBin: true
+
+ pbf@5.1.0:
+ resolution: {integrity: sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==}
+ hasBin: true
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1402,6 +1474,9 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
+ potpack@2.1.0:
+ resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
+
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -1409,6 +1484,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ protocol-buffers-schema@3.6.1:
+ resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
+
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
@@ -1420,6 +1498,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ quickselect@3.0.0:
+ resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
+
react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies:
@@ -1456,6 +1537,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ resolve-protobuf-schema@2.1.0:
+ resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
+
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
@@ -1580,6 +1664,9 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
+ tinyqueue@3.0.0:
+ resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
+
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
@@ -1997,6 +2084,47 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@mapbox/jsonlint-lines-primitives@2.0.3': {}
+
+ '@mapbox/point-geometry@1.1.0': {}
+
+ '@mapbox/tiny-sdf@2.2.0': {}
+
+ '@mapbox/unitbezier@0.0.1': {}
+
+ '@mapbox/unitbezier@1.0.0': {}
+
+ '@mapbox/vector-tile@2.0.5':
+ dependencies:
+ '@mapbox/point-geometry': 1.1.0
+ '@types/geojson': 7946.0.16
+ pbf: 4.0.2
+
+ '@mapbox/whoots-js@3.1.0': {}
+
+ '@maplibre/geojson-vt@6.1.1':
+ dependencies:
+ kdbush: 4.1.0
+
+ '@maplibre/maplibre-gl-style-spec@24.10.0':
+ dependencies:
+ '@mapbox/jsonlint-lines-primitives': 2.0.3
+ '@mapbox/unitbezier': 1.0.0
+ json-stringify-pretty-compact: 4.0.0
+ minimist: 1.2.8
+ quickselect: 3.0.0
+ tinyqueue: 3.0.0
+
+ '@maplibre/mlt@1.1.12':
+ dependencies:
+ '@mapbox/point-geometry': 1.1.0
+
+ '@maplibre/vt-pbf@4.3.2':
+ dependencies:
+ '@mapbox/point-geometry': 1.1.0
+ '@types/geojson': 7946.0.16
+ pbf: 5.1.0
+
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -2205,6 +2333,8 @@ snapshots:
'@types/estree@1.0.9': {}
+ '@types/geojson@7946.0.16': {}
+
'@types/node@22.19.1':
dependencies:
undici-types: 6.21.0
@@ -2419,6 +2549,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ earcut@3.2.3: {}
+
electron-to-chromium@1.5.348: {}
entities@8.0.0: {}
@@ -2510,6 +2642,8 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
+ gl-matrix@3.4.4: {}
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -2588,6 +2722,10 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ json-stringify-pretty-compact@4.0.0: {}
+
+ kdbush@4.1.0: {}
+
lightningcss-android-arm64@1.32.0:
optional: true
@@ -2657,6 +2795,28 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ maplibre-gl@5.24.0:
+ dependencies:
+ '@mapbox/jsonlint-lines-primitives': 2.0.3
+ '@mapbox/point-geometry': 1.1.0
+ '@mapbox/tiny-sdf': 2.2.0
+ '@mapbox/unitbezier': 0.0.1
+ '@mapbox/vector-tile': 2.0.5
+ '@mapbox/whoots-js': 3.1.0
+ '@maplibre/geojson-vt': 6.1.1
+ '@maplibre/maplibre-gl-style-spec': 24.10.0
+ '@maplibre/mlt': 1.1.12
+ '@maplibre/vt-pbf': 4.3.2
+ '@types/geojson': 7946.0.16
+ earcut: 3.2.3
+ gl-matrix: 3.4.4
+ kdbush: 4.1.0
+ murmurhash-js: 1.0.0
+ pbf: 4.0.2
+ potpack: 2.1.0
+ quickselect: 3.0.0
+ tinyqueue: 3.0.0
+
math-intrinsics@1.1.0: {}
mdn-data@2.27.1: {}
@@ -2676,6 +2836,10 @@ snapshots:
min-indent@1.0.1: {}
+ minimist@1.2.8: {}
+
+ murmurhash-js@1.0.0: {}
+
mz@2.7.0:
dependencies:
any-promise: 1.3.0
@@ -2729,6 +2893,14 @@ snapshots:
pathe@2.0.3: {}
+ pbf@4.0.2:
+ dependencies:
+ resolve-protobuf-schema: 2.1.0
+
+ pbf@5.1.0:
+ dependencies:
+ resolve-protobuf-schema: 2.1.0
+
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -2790,6 +2962,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ potpack@2.1.0: {}
+
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
@@ -2802,12 +2976,16 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ protocol-buffers-schema@3.6.1: {}
+
proxy-from-env@2.1.0: {}
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
+ quickselect@3.0.0: {}
+
react-dom@19.2.5(react@19.2.5):
dependencies:
react: 19.2.5
@@ -2841,6 +3019,10 @@ snapshots:
require-from-string@2.0.2: {}
+ resolve-protobuf-schema@2.1.0:
+ dependencies:
+ protocol-buffers-schema: 3.6.1
+
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
@@ -3013,6 +3195,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
+ tinyqueue@3.0.0: {}
+
tinyrainbow@3.1.0: {}
tldts-core@7.4.0: {}
diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock
index b27376fb..20b5bc80 100644
--- a/frontend/src-tauri/Cargo.lock
+++ b/frontend/src-tauri/Cargo.lock
@@ -746,7 +746,7 @@ dependencies = [
[[package]]
name = "find-desktop"
-version = "0.0.1"
+version = "1.1.0"
dependencies = [
"serde",
"serde_json",
diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml
index 996480a6..c894d7c7 100644
--- a/frontend/src-tauri/Cargo.toml
+++ b/frontend/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "find-desktop"
-version = "0.0.1"
+version = "1.1.0"
description = "Find - Local AI Image Intelligence desktop shell"
authors = []
edition = "2021"
diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json
index 49c760ad..610595bd 100644
--- a/frontend/src-tauri/tauri.conf.json
+++ b/frontend/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"productName": "Find",
- "version": "0.0.1",
+ "version": "1.1.0",
"identifier": "com.abhashchakraborty.find",
"build": {
"beforeDevCommand": "pnpm dev",
From 5315afe03729f9e5af93f0a7d2cc64273aafbec2 Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Mon, 13 Jul 2026 02:11:07 +0530
Subject: [PATCH 04/21] ci: validate and publish modular release profiles
---
.github/workflows/ci.yml | 58 +++++++-
.github/workflows/manual-ghcr-publish.yml | 128 ------------------
.github/workflows/publish.yml | 157 ++++++++++++++++++++++
.github/workflows/testsprite.yml | 8 +-
4 files changed, 212 insertions(+), 139 deletions(-)
delete mode 100644 .github/workflows/manual-ghcr-publish.yml
create mode 100644 .github/workflows/publish.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e528f1ac..de7b10a0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,17 +7,41 @@ name: CI
# frontend/** | yes | no | no
# backend/** | no | yes | no
# .github/workflows/** | yes | yes | yes
-# docker-compose*.yml | yes | yes | yes
+# compose*.yml | yes | yes | yes
# root/shared non-doc config | yes | yes | no
# docs/**, *.md, ISSUE_TEMPLATE | no | no | no
on:
+ workflow_dispatch:
push:
branches: ["main"]
pull_request:
branches: ["main"]
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
+ reference-boundary:
+ name: Reference source boundary
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ persist-credentials: false
+
+ - name: Reject tracked reference source
+ shell: bash
+ run: |
+ if [[ -n "$(git ls-files -- 'reference-app/**' '@reference-app/**')" ]]; then
+ echo "Reference application source must stay local and untracked." >&2
+ exit 1
+ fi
+
detect-changes:
runs-on: ubuntu-latest
outputs:
@@ -40,7 +64,7 @@ jobs:
- 'backend/**'
shared:
- '.github/workflows/**'
- - 'docker-compose*.yml'
+ - 'compose*.yml'
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d
id: uncategorized
@@ -52,7 +76,7 @@ jobs:
- '!frontend/**'
- '!backend/**'
- '!.github/workflows/**'
- - '!docker-compose*.yml'
+ - '!compose*.yml'
- '!docs/**'
- '!**/*.md'
- '!LICENSE'
@@ -193,13 +217,33 @@ jobs:
needs: detect-changes
if: needs.detect-changes.outputs.shared == 'true'
runs-on: ubuntu-latest
+ timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ file:
+ - compose.yml
+ - compose.base.yml
+ - compose.no-ai.yml
+ - compose.mock.yml
+ - compose.cpu.yml
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
persist-credentials: false
- - name: Validate docker-compose.yml
- run: docker compose config --quiet
+ - name: Validate ${{ matrix.file }}
+ run: docker compose --env-file .env.example -f "${{ matrix.file }}" config --quiet
- - name: Validate docker-compose.light.yml
- run: docker compose -f docker-compose.light.yml config --quiet
+ dependency-review:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ persist-credentials: false
+ - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294
+ with:
+ fail-on-severity: high
diff --git a/.github/workflows/manual-ghcr-publish.yml b/.github/workflows/manual-ghcr-publish.yml
deleted file mode 100644
index 8e8e5342..00000000
--- a/.github/workflows/manual-ghcr-publish.yml
+++ /dev/null
@@ -1,128 +0,0 @@
-name: Manual GHCR Publish
-
-on:
- workflow_dispatch:
- inputs:
- image_tag:
- description: "Image tag to publish"
- required: true
- default: "latest"
- backend_flavor:
- description: "Backend image flavor"
- required: true
- default: "light"
- type: choice
- options:
- - light
- - full-ml
-
-permissions:
- contents: read
- packages: write
-
-jobs:
- publish:
- name: Build and publish images from main
- runs-on: ubuntu-latest
- steps:
- - name: Check out main
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- with:
- ref: main
- persist-credentials: false
-
- - name: Prepare image names
- id: names
- shell: bash
- env:
- IMAGE_TAG: ${{ inputs.image_tag }}
- BACKEND_FLAVOR: ${{ inputs.backend_flavor }}
- run: |
- owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
- tag="${IMAGE_TAG}"
- if [[ ! "${tag}" =~ ^[A-Za-z0-9._-]+$ ]]; then
- echo "image_tag may only contain letters, numbers, dots, underscores, and dashes." >&2
- exit 1
- fi
- sha_tag="main-$(git rev-parse --short=12 HEAD)"
- if [ "${BACKEND_FLAVOR}" = "full-ml" ]; then
- backend_tags="ghcr.io/${owner}/find-backend:${tag}-full-ml,ghcr.io/${owner}/find-backend:${sha_tag}-full-ml,ghcr.io/${owner}/find-backend:full-ml"
- else
- backend_tags="ghcr.io/${owner}/find-backend:${tag},ghcr.io/${owner}/find-backend:${sha_tag},ghcr.io/${owner}/find-backend:latest"
- fi
- {
- echo "backend_image=ghcr.io/${owner}/find-backend"
- echo "web_image=ghcr.io/${owner}/find-web"
- echo "backend_tags=${backend_tags}"
- echo "web_tags=ghcr.io/${owner}/find-web:${tag},ghcr.io/${owner}/find-web:${sha_tag},ghcr.io/${owner}/find-web:latest"
- } >> "$GITHUB_OUTPUT"
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
-
- - name: Log in to GHCR
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Build and push lightweight backend image
- if: inputs.backend_flavor == 'light'
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
- with:
- context: ./backend
- file: ./backend/Dockerfile
- push: true
- platforms: linux/amd64,linux/arm64
- build-args: |
- BASE_IMAGE=python:3.12-slim
- PYTHON_VERSION=3.12
- UV_SYNC_EXTRAS=
- tags: ${{ steps.names.outputs.backend_tags }}
- labels: |
- org.opencontainers.image.source=https://github.com/${{ github.repository }}
- org.opencontainers.image.description=Find FastAPI backend, lightweight mock-mode image
-
- - name: Build and push full ML backend image
- if: inputs.backend_flavor == 'full-ml'
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
- with:
- context: ./backend
- file: ./backend/Dockerfile
- push: true
- platforms: linux/amd64
- build-args: |
- BASE_IMAGE=nvidia/cuda:12.1.1-runtime-ubuntu22.04
- PYTHON_VERSION=3.12
- UV_SYNC_EXTRAS=--extra ml
- tags: ${{ steps.names.outputs.backend_tags }}
- labels: |
- org.opencontainers.image.source=https://github.com/${{ github.repository }}
- org.opencontainers.image.description=Find FastAPI backend, full local ML image
-
- - name: Build and push web image
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
- with:
- context: ./frontend
- file: ./frontend/Dockerfile
- target: production
- push: true
- platforms: linux/amd64,linux/arm64
- build-args: |
- NEXT_PUBLIC_API_URL=http://localhost:8000
- NEXT_PUBLIC_MINIO_URL=http://localhost:9200
- NEXT_PUBLIC_MINIO_BUCKET=images
- tags: ${{ steps.names.outputs.web_tags }}
- labels: |
- org.opencontainers.image.source=https://github.com/${{ github.repository }}
- org.opencontainers.image.description=Find Next.js web UI
-
- - name: Publish summary
- shell: bash
- env:
- BACKEND_TAGS: ${{ steps.names.outputs.backend_tags }}
- WEB_TAGS: ${{ steps.names.outputs.web_tags }}
- run: |
- echo "Published backend tags: ${BACKEND_TAGS}" >> "$GITHUB_STEP_SUMMARY"
- echo "Published web tags: ${WEB_TAGS}" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 00000000..197ed70a
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,157 @@
+name: Publish release images
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+ inputs:
+ image_tag:
+ description: "Image tag to publish"
+ required: true
+ default: "edge"
+ backend_profile:
+ description: "Profile to publish (tag releases publish every profile)"
+ required: true
+ default: "nvidia"
+ type: choice
+ options:
+ - no-ai
+ - mock
+ - cpu
+ - nvidia
+
+permissions:
+ contents: read
+ packages: write
+
+concurrency:
+ group: publish-${{ github.ref }}-${{ inputs.image_tag || github.ref_name }}
+ cancel-in-progress: false
+
+jobs:
+ metadata:
+ name: Resolve immutable image metadata
+ runs-on: ubuntu-latest
+ outputs:
+ owner: ${{ steps.meta.outputs.owner }}
+ tag: ${{ steps.meta.outputs.tag }}
+ sha_tag: ${{ steps.meta.outputs.sha_tag }}
+ profiles: ${{ steps.meta.outputs.profiles }}
+ steps:
+ - name: Check out the selected revision
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ persist-credentials: false
+
+ - name: Resolve tags
+ id: meta
+ shell: bash
+ env:
+ REQUESTED_TAG: ${{ inputs.image_tag }}
+ REQUESTED_PROFILE: ${{ inputs.backend_profile }}
+ run: |
+ owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
+ tag="${REQUESTED_TAG:-${GITHUB_REF_NAME#v}}"
+ if [[ ! "${tag}" =~ ^[A-Za-z0-9._-]+$ ]]; then
+ echo "Image tag contains unsupported characters." >&2
+ exit 1
+ fi
+ echo "owner=${owner}" >> "$GITHUB_OUTPUT"
+ echo "tag=${tag}" >> "$GITHUB_OUTPUT"
+ echo "sha_tag=sha-$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
+ if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
+ echo 'profiles=["no-ai","mock","cpu","nvidia"]' >> "$GITHUB_OUTPUT"
+ else
+ echo "profiles=[\"${REQUESTED_PROFILE}\"]" >> "$GITHUB_OUTPUT"
+ fi
+
+ backend:
+ name: Publish backend (${{ matrix.profile }})
+ needs: metadata
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ profile: ${{ fromJSON(needs.metadata.outputs.profiles) }}
+ steps:
+ - name: Check out source
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ persist-credentials: false
+
+ - name: Set up QEMU
+ if: matrix.profile != 'nvidia'
+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
+
+ - name: Log in to GHCR
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and publish profile
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
+ with:
+ context: ./backend
+ file: ./backend/Dockerfile
+ push: true
+ platforms: ${{ matrix.profile == 'nvidia' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
+ build-args: |
+ BASE_IMAGE=${{ matrix.profile == 'nvidia' && 'nvidia/cuda:12.1.1-runtime-ubuntu22.04' || 'python:3.12-slim' }}
+ PYTHON_VERSION=3.12
+ UV_SYNC_EXTRAS=${{ matrix.profile != 'no-ai' && format('--extra {0}', matrix.profile) || '' }}
+ FIND_BUILD_PROFILE=${{ matrix.profile }}
+ tags: |
+ ghcr.io/${{ needs.metadata.outputs.owner }}/find-backend:${{ needs.metadata.outputs.tag }}-${{ matrix.profile }}
+ ghcr.io/${{ needs.metadata.outputs.owner }}/find-backend:${{ needs.metadata.outputs.sha_tag }}-${{ matrix.profile }}
+ ghcr.io/${{ needs.metadata.outputs.owner }}/find-backend:${{ matrix.profile }}
+ labels: |
+ org.opencontainers.image.source=https://github.com/${{ github.repository }}
+ org.opencontainers.image.revision=${{ github.sha }}
+ org.opencontainers.image.version=${{ needs.metadata.outputs.tag }}
+ io.find.runtime.profile=${{ matrix.profile }}
+
+ web:
+ name: Publish web image
+ needs: metadata
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out source
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+ with:
+ persist-credentials: false
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
+
+ - name: Log in to GHCR
+ uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and publish web
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
+ with:
+ context: ./frontend
+ file: ./frontend/Dockerfile
+ target: production
+ push: true
+ platforms: linux/amd64,linux/arm64
+ tags: |
+ ghcr.io/${{ needs.metadata.outputs.owner }}/find-web:${{ needs.metadata.outputs.tag }}
+ ghcr.io/${{ needs.metadata.outputs.owner }}/find-web:${{ needs.metadata.outputs.sha_tag }}
+ ghcr.io/${{ needs.metadata.outputs.owner }}/find-web:latest
+ labels: |
+ org.opencontainers.image.source=https://github.com/${{ github.repository }}
+ org.opencontainers.image.revision=${{ github.sha }}
+ org.opencontainers.image.version=${{ needs.metadata.outputs.tag }}
diff --git a/.github/workflows/testsprite.yml b/.github/workflows/testsprite.yml
index 9cf6fce5..3bb82b87 100644
--- a/.github/workflows/testsprite.yml
+++ b/.github/workflows/testsprite.yml
@@ -61,7 +61,7 @@ jobs:
steps.testsprite-secret.outputs.available == 'true' &&
(github.event_name == 'pull_request' || github.event.inputs.base_url == '')
run: |
- docker compose -f docker-compose.light.yml up -d --build --wait --wait-timeout 240
+ docker compose -f compose.mock.yml up -d --build --wait --wait-timeout 240
- name: Wait for local preview
if: >
@@ -75,8 +75,8 @@ jobs:
fi
sleep 2
done
- docker compose -f docker-compose.light.yml ps
- docker compose -f docker-compose.light.yml logs --tail=120
+ docker compose -f compose.mock.yml ps
+ docker compose -f compose.mock.yml logs --tail=120
exit 1
- name: Run TestSprite
@@ -89,4 +89,4 @@ jobs:
- name: Stop local preview stack
if: always()
- run: docker compose -f docker-compose.light.yml down -v --remove-orphans
+ run: docker compose -f compose.mock.yml down -v --remove-orphans
From 16bd8d273f701654459142e381408329432c187c Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Mon, 13 Jul 2026 02:11:15 +0530
Subject: [PATCH 05/21] docs: prepare the v1.1 release candidate
---
.gitignore | 1 -
AGENTS.md | 4 +-
CHANGELOG.md | 33 ++-
CONTRIBUTING.md | 18 +-
GSSOC_CONTRIBUTOR_GUIDE.md | 14 +-
README.md | 78 ++++--
docs/guides/common-setup-errors.md | 2 +-
docs/guides/hardware-acceleration.md | 28 ++-
docs/guides/testsprite-ci.md | 2 +-
.../inventory/lane-a-timeline-grid.md | 131 -----------
.../inventory/lane-b-albums-sharing.md | 99 --------
.../lane-c-archive-favorites-trash.md | 106 ---------
.../inventory/lane-d-viewer-slideshow.md | 183 ---------------
docs/overhaul/inventory/lane-e-backend-api.md | 208 ----------------
docs/overhaul/inventory/lane-f-ml.md | 108 ---------
docs/overhaul/inventory/lane-g-settings.md | 222 ------------------
.../inventory/lane-h-mobile-desktop.md | 52 ----
.../not-started/desktop-runtime-comparison.md | 2 +-
docs/plans/not-started/remote-acceleration.md | 2 +-
.../storage-provider-neutrality-adr.md | 8 +-
plan.md | 62 +++--
21 files changed, 188 insertions(+), 1175 deletions(-)
delete mode 100644 docs/overhaul/inventory/lane-a-timeline-grid.md
delete mode 100644 docs/overhaul/inventory/lane-b-albums-sharing.md
delete mode 100644 docs/overhaul/inventory/lane-c-archive-favorites-trash.md
delete mode 100644 docs/overhaul/inventory/lane-d-viewer-slideshow.md
delete mode 100644 docs/overhaul/inventory/lane-e-backend-api.md
delete mode 100644 docs/overhaul/inventory/lane-f-ml.md
delete mode 100644 docs/overhaul/inventory/lane-g-settings.md
delete mode 100644 docs/overhaul/inventory/lane-h-mobile-desktop.md
diff --git a/.gitignore b/.gitignore
index 9d99e56d..caef8155 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,4 +74,3 @@ minio_data/
# Local reference copy of the AGPL-3.0 reference project — reference only, never committed
reference-app/
-immich/
diff --git a/AGENTS.md b/AGENTS.md
index 99f327da..b662e8c7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -31,7 +31,7 @@ Find is a local-first AI image intelligence app. Key paths:
- `backend/src/find_api/` - FastAPI API, SQLAlchemy models, Redis/RQ jobs, MinIO helpers, and ML wrappers.
- `frontend/src/app/` - Next.js App Router UI.
- `frontend/src/lib/` - React Query API client, media URL helpers, and shared utilities.
-- `docker-compose.yml` - PostgreSQL/pgvector, Redis, MinIO, API, worker, and web orchestration.
+- `compose.yml` and `compose.*.yml` - modular PostgreSQL, storage, API, worker, web, and AI profiles.
- `.env.example` - documented local configuration. Keep real `.env` files private.
- `.github/workflows/ci.yml` - frontend and backend CI checks.
@@ -78,7 +78,7 @@ Run the API and worker separately when not using Docker.
Prefer the light stack for routine UI, API, docs, and workflow work:
```bash
-docker compose -f docker-compose.light.yml up --build
+docker compose -f compose.mock.yml up --build
```
Use the full stack only when the change needs real ML behavior.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9bb7f4db..317de847 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,15 +4,38 @@ All notable changes to Find are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/), and this project is
distributed under AGPL-3.0 (see `LICENSE` / `NOTICE`).
-## [Unreleased] — App overhaul (`feat/app-overhaul`)
+## [1.1.0] — 2026-07-13
-A large feature overhaul bringing reference-grade browsing, albums, sharing,
-archive/trash, and a hardware-acceleration layer. All changes below are on the
-`feat/app-overhaul` branch and verified by the test suites (backend pytest +
-frontend vitest) with a clean `tsc --noEmit` and `ruff check`.
+A product and platform overhaul bringing timeline-first browsing, account and
+vault management, a private offline map, modular AI artifacts, and a polished
+responsive application shell.
### Added
+**Application shell and account**
+- Responsive sidebar/top bar, focus-safe mobile navigation, global search
+ shortcut, theme persistence, and consistent route-level spacing.
+- Setup, login, profile, password rotation, active-session listing, and session
+ revocation backed by secure HTTP-only session cookies.
+
+**Private map and vault**
+- Opt-in EXIF GPS retention with an account-scoped offline MapLibre view using
+ bundled Natural Earth geometry and no external tile or geocoding requests.
+- Explicit vault session locking, session-only decrypted thumbnails, timeline
+ browsing, full-screen preview, and rollback-safe restoration.
+
+**Modular runtime**
+- Separate no-AI, mock, CPU, and NVIDIA dependency artifacts. CPU/no-AI builds
+ do not install CUDA packages; the dashboard reports the installed artifact,
+ applied mode, worker health, and restart requirements.
+- Dashboard selection of disabled, mock, or full processing whenever that mode
+ is installed in the active artifact, without restarting or downloading a
+ different dependency stack.
+- Consolidated Compose topology: `compose.yml`, `compose.base.yml`, and explicit
+ `compose.no-ai.yml`, `compose.mock.yml`, and `compose.cpu.yml` profiles.
+- Tag-driven and manual GHCR publishing for immutable web images and all four
+ modular backend profiles.
+
**Timeline**
- Month-bucketed timeline API: `GET /api/timeline/buckets` (counts per month)
and `GET /api/timeline/bucket` (columnar per-asset window with aspect ratio).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 14d6432d..ed78c0cc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -35,7 +35,7 @@ For repo-aware coding-agent and contributor guidance, start with [AGENTS.md](./A
For most UI, API, docs, and workflow contributions, start with the light stack:
```bash
-docker compose -f docker-compose.light.yml up --build
+docker compose -f compose.mock.yml up --build
```
This uses `ML_MODE=mock`, skips GPU access, and avoids downloading Florence-2, SigLIP, PaddleOCR, YOLO, and CUDA PyTorch assets. Upload, worker processing, gallery, search, and clustering still run end-to-end with deterministic mock metadata and vectors.
@@ -46,9 +46,15 @@ Use the full stack only when your change needs real ML inference:
docker compose up --build
```
+On a machine without NVIDIA support, use the locked CPU-only AI artifact:
+
+```bash
+docker compose -f compose.cpu.yml up --build
+```
+
## Mock mode vs full ML mode
-The light stack (`docker-compose.light.yml`) runs with `ML_MODE=mock`. The worker
+The mock stack (`compose.mock.yml`) runs with `ML_MODE=mock`. The worker
records real image dimensions and EXIF data but replaces all model outputs with
deterministic stubs:
@@ -60,7 +66,7 @@ deterministic stubs:
### When the light stack is enough
-Use `docker-compose.light.yml` (mock mode) when your change involves:
+Use `compose.mock.yml` when your change involves:
- Any frontend code (UI, layout, styling, components)
- API routing, request/response shapes, validation, or error handling
@@ -98,7 +104,8 @@ uv sync --group dev
uv run uvicorn find_api.main:app --reload
```
-Use `uv sync --group dev --extra ml` only when you need real local ML inference outside Docker.
+Use `uv sync --group dev --extra cpu` for real CPU inference outside Docker or
+`uv sync --group dev --extra nvidia` for CUDA. Do not enable both extras.
Worker:
@@ -213,4 +220,5 @@ Useful labels:
## License
-By contributing, you agree your contributions are licensed under the [MIT License](./LICENSE).
+By contributing, you agree your contributions are licensed under the
+[GNU Affero General Public License v3.0](./LICENSE).
diff --git a/GSSOC_CONTRIBUTOR_GUIDE.md b/GSSOC_CONTRIBUTOR_GUIDE.md
index 42b5705b..8bf056c6 100644
--- a/GSSOC_CONTRIBUTOR_GUIDE.md
+++ b/GSSOC_CONTRIBUTOR_GUIDE.md
@@ -27,8 +27,8 @@ Important paths:
- `frontend/src/app/` - Next.js pages and UI.
- `frontend/src/lib/` - frontend API client and shared helpers.
- `backend/src/find_api/` - FastAPI app, routers, models, storage, queue, worker, and ML wrappers.
-- `docker-compose.yml` - full GPU-oriented stack.
-- `docker-compose.light.yml` - lightweight contributor stack.
+- `compose.yml` - default NVIDIA GPU stack.
+- `compose.mock.yml` - lightweight contributor stack.
- `.env.example` - documented local environment values.
## Contribution workflow
@@ -49,7 +49,7 @@ Most GSSoC work should start with the light stack. It avoids the 30-40 GB first-
From the repository root:
```bash
-docker compose -f docker-compose.light.yml up --build
+docker compose -f compose.mock.yml up --build
```
What light mode gives you:
@@ -68,7 +68,7 @@ Use light mode for:
## Understanding mock mode output
-The light stack (`docker-compose.light.yml`) is the recommended starting point for all
+The mock stack (`compose.mock.yml`) is the recommended starting point for all
GSSoC contributors because it avoids large model downloads and GPU requirements.
However, it runs with `ML_MODE=mock`, and understanding what that means prevents a
common class of false bug reports.
@@ -120,7 +120,7 @@ docker compose up --build
> ⚠️ **Do not report caption or search quality issues observed in mock mode.**
>
-> If you ran `docker compose -f docker-compose.light.yml up --build` and noticed that
+> If you ran `docker compose -f compose.mock.yml up --build` and noticed that
> captions look wrong, search returns unrelated images, or objects are not detected —
> that is expected mock behavior and is not a bug. Open a full-stack environment and
> reproduce the issue there before filing a report or opening a PR that claims to fix
@@ -174,7 +174,7 @@ Real local ML dependencies:
```bash
cd backend
-uv sync --group dev --extra ml
+uv sync --group dev --extra cpu
```
Manual local setup also requires PostgreSQL with `pgvector`, Redis, and MinIO.
@@ -211,7 +211,7 @@ Compose validation:
```bash
docker compose config
-docker compose -f docker-compose.light.yml config
+docker compose -f compose.mock.yml config
```
## Manual testing checklist
diff --git a/README.md b/README.md
index 3b3bd58a..3a91196f 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ See the documentation index in [`docs/index.md`](./docs/index.md), the mobile di
- Inspect full-resolution images with zoom, keyboard navigation, and slideshow
- Share albums with scoped links and optional passwords/download controls
- Organize media with favorites, archive, recoverable trash, and near-duplicate review
-- Protect hidden images in an encrypted local vault; full design alignment remains in progress
+- Protect hidden images in an encrypted local vault with explicit in-memory sessions, timeline browsing, preview, restore, and lock controls
- Record local feedback for search, captions, objects, and people grouping
## Tech stack
@@ -43,16 +43,19 @@ See the documentation index in [`docs/index.md`](./docs/index.md), the mobile di
## Runtime profiles
-| Profile | Command | Intended use | ML behavior |
+| Artifact | Command | Included AI runtime | GPU/CUDA download |
| --- | --- | --- | --- |
-| Light | `docker compose -f docker-compose.light.yml up --build` | UI, API, docs, contributor work, and low-resource smoke tests | Deterministic mock inference; no model downloads or GPU required |
-| Full | `docker compose up --build` | Real caption, OCR, detection, embedding, face, and search-quality validation | Downloads the configured local models and currently expects NVIDIA GPU support |
-
-The full profile is intentionally much larger because it includes CUDA PyTorch,
-PaddleOCR/PaddlePaddle, ONNX Runtime, Florence-2, SigLIP, YOLO26 nano, and
-InsightFace. Use the light profile unless the change specifically needs real ML
-quality or hardware measurements. Model-size and CPU-only packaging improvements
-are tracked in GitHub issues rather than silently changing model behavior.
+| No AI | `docker compose -f compose.no-ai.yml up --build` | None; thumbnails, dimensions, EXIF, gallery, albums, vault, and maps remain available | No |
+| Mock | `docker compose -f compose.mock.yml up --build` | Deterministic test metadata/vectors | No |
+| CPU AI | `docker compose -f compose.cpu.yml up --build` | Real local captioning, OCR, detection, embeddings, faces, and clustering with CPU PyTorch/ONNX | No |
+| NVIDIA AI | `docker compose up --build` | Real local AI with CUDA PyTorch/ONNX providers | Yes |
+
+The default `compose.yml` is the NVIDIA profile. Explicit profiles extend
+`compose.base.yml`, keeping application and data services centralized while
+each backend image installs only its selected dependency extra. Selecting a
+profile is a build/deployment choice;
+the dashboard can enable/disable installed AI and choose Auto/GPU/CPU, but it
+cannot install missing packages into a running container.
## Architecture
@@ -116,14 +119,27 @@ Services:
Notes:
- Current Docker setup is GPU-oriented and expects NVIDIA GPU access.
-- If no root `.env` is present, compose defaults support local demo startup.
+- Copy `.env.example` to `.env` before startup. Compose intentionally has no
+ embedded service-password fallback.
+
+For the same real local AI pipeline without CUDA or an NVIDIA runtime, use:
+
+```bash
+docker compose -f compose.cpu.yml up --build
+```
+
+For metadata-only operation with no AI dependencies or model downloads, use:
+
+```bash
+docker compose -f compose.no-ai.yml up --build
+```
### Option B: fast contributor mode (recommended for most work)
For UI, API, upload, gallery, search, clustering, docs, and workflow changes, use the light stack:
```bash
-docker compose -f docker-compose.light.yml up --build
+docker compose -f compose.mock.yml up --build
```
This runs the same app flow with `ML_MODE=mock`, a Python slim backend image, and no GPU/model cache mount. It avoids downloading Florence-2, SigLIP, PaddleOCR, YOLO, CUDA PyTorch, and related model weights, so first-time setup is much smaller and faster.
@@ -142,7 +158,7 @@ Find ships two runtime modes that serve different purposes. Choosing the wrong o
### Mock mode (light stack)
```bash
-docker compose -f docker-compose.light.yml up --build
+docker compose -f compose.mock.yml up --build
```
`ML_MODE=mock` is set automatically. The worker skips all model loading and instead records:
@@ -199,6 +215,34 @@ The worker loads Florence-2 (captioning), YOLOv10 (object detection), PaddleOCR
First run of the full stack downloads Florence-2, SigLIP, PaddleOCR, and YOLO weights (several GB). Models are cached in the `model_cache` Docker volume and reused on subsequent runs.
+## Controlling AI from the dashboard
+
+The account/settings dashboard persists four instance-wide runtime choices:
+
+- `ai_enabled` turns the installed AI pipeline on or off.
+- `ml_mode` switches between the modes already present in the artifact. CPU and
+ NVIDIA builds can move directly between disabled, mock, and full local AI;
+ lightweight builds never pretend that missing model packages are available.
+- `accel_mode` selects `auto`, `gpu`, or `cpu`; unsupported GPU requests fall
+ back to CPU inside CPU/NVIDIA artifacts.
+- `map_enabled` opts in to retaining GPS coordinates from EXIF for the private map.
+
+Workers read all three values at the start of every job, so new jobs use the
+saved choice without mutating a worker's process environment. Inspect
+`GET /api/config/runtime` to compare the selected build/mode with the last state
+actually applied by a worker. If that endpoint says `restart_required: true`,
+start the CPU or NVIDIA compose artifact; a no-AI/mock image cannot become a
+full image through a toggle.
+
+Release tags (`v*`) publish immutable web images plus separate `no-ai`, `mock`,
+`cpu`, and `nvidia` backend images through `.github/workflows/publish.yml`.
+Manual runs can publish one selected profile without building unrelated AI
+dependencies.
+
+`ML_MODE=remote` is intentionally fail-closed for now: no remote inference
+adapter is installed, the runtime reports `unavailable`, and Find never sends
+private media to a remote service or silently falls back to local models.
+
### Option C: local development without Docker
#### Prerequisites
@@ -225,7 +269,9 @@ uv sync --group dev
uv run uvicorn find_api.main:app --reload
```
-Use `uv sync --group dev --extra ml` only when you need real local ML inference outside Docker.
+Use `uv sync --group dev --extra cpu` for real CPU inference outside Docker, or
+`uv sync --group dev --extra nvidia` for the locked CUDA build. The two extras
+are intentionally mutually exclusive.
#### 3. Worker (separate terminal)
@@ -365,7 +411,7 @@ docker compose logs --tail=200 api
- Model downloads happen on the first startup of the full stack.
- Cached models are stored in the Docker volume mounted at `model_cache`.
-- Use `docker compose -f docker-compose.light.yml up --build` when you only need to test contributor changes without real ML inference.
+- Use `docker compose -f compose.mock.yml up --build` when you only need to test contributor changes without real ML inference.
### Docker disk usage
@@ -387,7 +433,7 @@ docker compose exec api sh -lc "rm -rf /root/.cache/uv"
- Prefer the light stack for routine UI/API/docs work when you do not need real inference:
```bash
-docker compose -f docker-compose.light.yml up --build
+docker compose -f compose.mock.yml up --build
```
## Contribution quick start
diff --git a/docs/guides/common-setup-errors.md b/docs/guides/common-setup-errors.md
index 0c0b8efb..4b76aef0 100644
--- a/docs/guides/common-setup-errors.md
+++ b/docs/guides/common-setup-errors.md
@@ -152,7 +152,7 @@ Expected ports:
Use the light Docker stack for routine contributor work:
```bash
-docker compose -f docker-compose.light.yml up --build
+docker compose -f compose.mock.yml up --build
```
- Use the full stack only when testing real ML inference, captions, OCR, search
diff --git a/docs/guides/hardware-acceleration.md b/docs/guides/hardware-acceleration.md
index 595e9c3e..03e6605d 100644
--- a/docs/guides/hardware-acceleration.md
+++ b/docs/guides/hardware-acceleration.md
@@ -72,18 +72,36 @@ ACCEL_MODE=auto
Or change it at runtime from **Settings → Hardware acceleration**.
+The dashboard preference is persisted in `app_settings` and sampled at each
+API inference request/worker job boundary. `GET /api/config/runtime` reports
+both the desired state and the last state a worker actually applied.
+
### Legacy `USE_GPU`
Earlier versions used a boolean `USE_GPU`. For backward compatibility,
`USE_GPU=false` is still honored as a hard CPU pin when `ACCEL_MODE` is left at
`auto`. New deployments should prefer `ACCEL_MODE`.
-## CPU-only deployments
+## Install-time profiles
+
+Acceleration fallback cannot remove CUDA packages that were already baked into
+an image, so Find provides separate locked artifacts:
+
+```bash
+# Real AI with CPU-only PyTorch and ONNX Runtime; no CUDA wheels.
+docker compose -f compose.cpu.yml up --build
+
+# Real AI with CUDA PyTorch and onnxruntime-gpu.
+docker compose up --build
+
+# Deterministic fake inference, or metadata-only with no AI extra at all.
+docker compose -f compose.mock.yml up --build
+docker compose -f compose.no-ai.yml up --build
+```
-On a machine with no GPU, no configuration is required — `auto` resolves to CPU
-and the full pipeline runs. For the lightest footprint, set `ACCEL_MODE=cpu`
-explicitly. (CPU-friendly model variants such as a quantized embedding model are
-tracked as a follow-on; the current models run on CPU, just slower than on GPU.)
+Choose the CPU artifact on a machine without an NVIDIA runtime. Setting
+`ACCEL_MODE=cpu` in an NVIDIA image changes execution but does not shrink that
+image; conversely, setting `gpu` in a CPU image cannot install CUDA packages.
## Troubleshooting
diff --git a/docs/guides/testsprite-ci.md b/docs/guides/testsprite-ci.md
index a8e08984..d078a018 100644
--- a/docs/guides/testsprite-ci.md
+++ b/docs/guides/testsprite-ci.md
@@ -19,7 +19,7 @@ to the repository.
- The workflow triggers on pull requests to `main`.
- Draft pull requests are skipped.
- Every non-draft PR is tested when the TestSprite secret is available.
-- App PRs start `docker-compose.light.yml` in GitHub Actions.
+- App PRs start `compose.mock.yml` in GitHub Actions.
- The light stack runs in mock ML mode, so it avoids GPU/model downloads.
- TestSprite is pointed at `http://127.0.0.1:3000`.
diff --git a/docs/overhaul/inventory/lane-a-timeline-grid.md b/docs/overhaul/inventory/lane-a-timeline-grid.md
deleted file mode 100644
index 427a2664..00000000
--- a/docs/overhaul/inventory/lane-a-timeline-grid.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# Lane A — Timeline & Grid Inventory
-
-Scope: justified grid layout, fast date-scrubber scrollbar, segment/bucket preview, virtualized rendering.
-Reference app: `reference-app/web/src/lib/` (Svelte 5 runes + Immich-style SDK). Read-only.
-
----
-
-## 1. Behaviors
-
-Justified grid layout
-- Per-row justified layout from per-asset aspect ratios; ratios packed into a `Float32Array`, fed to either a WASM layout engine or the JS `justified-layout` lib. Options: target `rowHeight`, container `rowWidth`, `spacing`, `heightTolerance`. `layout-utils.ts:30-116`.
-- Layout exposes a box-geometry interface (`getTop/getLeft/getWidth/getHeight/getPosition(boxIdx)`, `containerWidth/Height`) so render code positions each tile absolutely. `layout-utils.ts:13-21`.
-- Two-level layout: each **month** lays out its **day groups** independently, then day groups are flowed into rows within the month, wrapping when `cumulativeWidth + itemWidth > viewportWidth`, accumulating `month.height`. Each day group carries `row/col/start(left)/top`. `layout-support.svelte.ts:23-70`.
-- Default tunables: `rowHeight=235`, `headerHeight=48`, `gap=12`, `spacing=2`, `heightTolerance=0.5`. `VirtualScrollManager.svelte.ts:22-34,145`.
-
-Height estimation before load (skeleton geometry)
-- For not-yet-loaded months, height is *estimated* without real ratios: `unwrappedWidth = 1.5 * assetCount * rowHeight * 0.7`, `rows = ceil(unwrappedWidth / viewportWidth)`, `height = headerHeight + max(1,rows)*rowHeight`. This lets the full scroll height + scrubber exist before any bucket is fetched. `layout-support.svelte.ts:10-19`.
-
-Virtualized rendering (windowing)
-- Total scroll height = `topSectionHeight + bodySectionHeight + bottomSectionHeight`; `bodySectionHeight` = sum of all `month.height`. `VirtualScrollManager.svelte.ts:12`, `timeline-manager.svelte.ts:53-59`.
-- A `visibleWindow {top,bottom}` derives from `scrollTop` + `viewportHeight`. `VirtualScrollManager.svelte.ts:14-17`.
-- Each month gets a `ViewportProximity` of `InViewport | NearViewport | FarFromViewport`, computed by intersecting `[month.top, month.top+height]` against the window expanded by `INTERSECTION_EXPAND_TOP/BOTTOM` tunables. Only in/near months are laid out for real and rendered; far months keep estimated height only. `intersection-support.svelte.ts:31-55`.
-- Scroll handler updates the sliding window (`updateSlidingWindow`) which recomputes proximities only when `scrollTop` actually changed. `VirtualScrollManager.svelte.ts:155-161`.
-- Deferred layout: far months defer real justified layout; cleared (`clearDeferredLayout`) when they come near viewport. `intersection-support.svelte.ts:52-54`.
-
-Lazy data load per month (buckets)
-- Months are created empty from a bucket-count list at init (count only, no assets). `timeline-manager.svelte.ts:247-266`.
-- Asset detail for a month is fetched on demand via `loadTimelineMonth` → `loadFromTimeBuckets` when the month enters/nears the viewport (or is iterated). Each fetch is a `CancellableTask` so off-screen loads can abort. `timeline-manager.svelte.ts:351-372`, `load-support.svelte.ts:8-60`.
-- On successful load, real geometry is computed and proximities refreshed. `timeline-manager.svelte.ts:368-371`.
-
-Scrub-to-date scrollbar (the fast scrubber)
-- Scrubber renders one **segment per month**, segment height proportional to that month's share of total scroll height (`month.height / scrubberTimelineHeight`). `Scrubber.svelte:144-192`, `timeline-manager.svelte.ts:340-349`.
-- Sparse labels/dots: walking months newest→oldest, a year label is placed only when the year changes AND accumulated span > `MIN_YEAR_LABEL_DISTANCE(16px)`; a dot only when segment >5px tall and span > `MIN_DOT_DISTANCE(8px)`. Prevents label crowding. `Scrubber.svelte:144-192`.
-- Has lead-in (top offset / header) and lead-out (bottom) pseudo-segments. `Scrubber.svelte:100-126`, `Timeline.svelte:333-340`.
-- Two-way binding: (a) dragging/hovering the scrubber → `onScrub` callback maps the cursor's segment + intra-segment percent to a `scrollTop` and calls `timelineManager.scrollTo`. `Timeline.svelte:268-307`. (b) on normal scroll, the timeline computes which month is at the viewport top and the percent through it (`viewportTopMonth`, `viewportTopMonthScrollPercent`) and feeds the scrubber thumb position. `Timeline.svelte:316-363`, `Scrubber.svelte:95-129`.
-- Hover shows a floating date label for the segment under the cursor; padding zones above/below clamp to first/last month label. `Scrubber.svelte:195-243`.
-- Mobile vs desktop: scrubber width 20px (mobile, only visible while scrolling) vs 60px (desktop); widens to full viewport while dragging. `Scrubber.svelte:71-90`.
-- "limitedScroll" edge case (content barely taller than viewport, `maxScrollPercent < 0.5`): falls back to a single overall scroll percent instead of per-month mapping. `timeline-manager.svelte.ts:76`, `Timeline.svelte:316-322`.
-
-Scroll-to-asset / scroll-to-date
-- Programmatic scroll to a given asset: find its month, ensure loaded, compute box top/bottom, scroll to whichever of align-top/align-bottom is nearer. `Timeline.svelte:128-197`.
-- Random/jump-to-date loads the target month then scrolls. `timeline-manager.svelte.ts:412-442`.
-
-Multi-select
-- Selection state: a `Map` plus selected day-groups set, `selectAll` flag, and `startAsset` for range anchoring. Derived flags: `selectionActive`, `isAllFavorite/Trashed/Archived/UserOwned`, `ownedAssets`. `asset-multi-select-manager.svelte.ts:10-105`.
-- Range/candidate selection: holding shift sets a start anchor; hovering computes a candidate range between anchor and current asset (`candidates`), committed on click. `asset-multi-select-manager.svelte.ts:83-93`, `Timeline.svelte:366-494` (shift handling at 474,494).
-- Reset on navigation event. `asset-multi-select-manager.svelte.ts:38-40`.
-
-Realtime updates
-- Websocket support upserts/updates/deletes assets into the right month, recomputing geometry; pending-change queue types Add/Update/Delete/Trash. `types.ts:55-75`, manager `connect()`/`upsertAssets` `timeline-manager.svelte.ts:172-178,374-377`.
-
----
-
-## 2. Data needs (endpoint contracts, abstract)
-
-The reference timeline rests on **two** contracts. Find currently has neither in this shape.
-
-A. Bucket-count list (the timeline skeleton) — REQUIRED, highest priority
-- Request: timeline filter params (owner/album/person/visibility/tag, sort order) + a bucket granularity (month).
-- Response: ordered list of `{ timeBucket: ISO-date (month start), count: number }`, sorted by the active order (newest- or oldest-first).
-- Purpose: lets the client build every month, estimate total scroll height, and render the full scrubber **before fetching a single photo**. This is what makes scrub-to-date instant. Used at `timeline-manager.svelte.ts:247-263`.
-
-B. Per-bucket asset window — REQUIRED
-- Request: same filter params + a single `timeBucket` (the month key) + size.
-- Response: the assets for exactly that month. Reference uses a columnar/struct-of-arrays bucket payload (parallel arrays keyed off the response) decoded into `TimelineAsset[]`. `load-support.svelte.ts:19-49`.
-- Per-asset fields the layout/UI actually need (`types.ts:18-41`): `id`, `ratio` (aspect ratio — **critical for justified layout**), `thumbhash` (blur placeholder), `localDateTime`, `isVideo/isImage`, `duration`, `isFavorite/isTrashed`, `visibility`, `ownerId`, optionally `city/country/people/lat/long`, stack info.
-
-Key contract note: granularity is **month buckets**, ordering is server-controlled and stable, and counts come separately from assets. The whole virtualization + scrubber design depends on knowing per-month counts up front.
-
----
-
-## 3. Find today + the gap
-
-What Find has (`frontend/src/components/virtualized-grid.tsx`, `app/gallery/page.tsx`, `store/galleryStore.ts`, `lib/api.ts`):
-- `VirtualizedGrid`: a CSS-grid windower. Reads `grid-template-columns` to count columns, assumes a **uniform** `estimateRowHeight + gap` row stride, computes start/end rows from `getBoundingClientRect().top` vs `window.innerHeight`, renders a top/bottom spacer + the visible slice. Listens to window + scroll-parent scroll/resize + ResizeObserver. `virtualized-grid.tsx:63-194`.
-- Data: offset pagination via `getGallery({page,limit,sortOrder,dateRange,dateStart,dateEnd})` → `GalleryResponse {items, total, page, limit}`, consumed with React Query `useInfiniteQuery` (page N+1 when `page*limit < total`). `api.ts:265-296`, `page.tsx:439-455`.
-- Sorting newest/oldest + a date-range *filter* (presets + custom start/end) already exist in `galleryStore` and the URL. `galleryStore.ts:4-26`, `api.ts:21-27`.
-- `MediaItem` has `width`/`height` (so aspect ratio is derivable) but **no thumbhash/blur placeholder, no per-asset localDate grouping field used client-side**. `api.ts:33-60`.
-- Counts endpoint exists but returns only status totals (`all/indexed/processing/failed`), **not per-date counts**. `api.ts:106-111,298-307`.
-
-GAP vs reference:
-1. **No justified layout.** Find uses a fixed-column square/uniform grid; reference packs variable-aspect rows. Tiles' real `width/height` are unused for layout.
-2. **No time-bucket data model.** Find paginates by offset/page; there's no month-bucket count list, so the full timeline height and the scrubber can't be known up front. This is the central architectural gap.
-3. **No date scrubber at all.** No fast scrub-to-date, no month segments, no hover-date preview, no scroll↔scrubber two-way sync.
-4. **No month/day headers or grouping** in the grid; reference groups by month and by day with sticky-ish headers and per-group geometry.
-5. **Uniform-height virtualization only.** Find's windower assumes every row is the same height; it cannot represent variable month heights or justified rows. It also relies on `window`/`getBoundingClientRect` rather than an owned scroll container with a derived sliding window.
-6. **No proximity/deferred-layout tier.** Find renders a flat slice; there's no "near vs far" distinction, no per-month lazy fetch, no cancellable off-screen loads.
-7. **Multi-select** exists in reference with range/shift + group selection; verify Find's gallery selection (not in scope files read) matches — at minimum range-by-shift and select-whole-day are reference behaviors to match.
-
----
-
-## 4. Port notes (Svelte 5 → React 19 / Next 16)
-
-State model
-- Reference leans on Svelte runes (`$state/$derived/$effect`) inside plain manager classes. In React, the cleanest port is a framework-agnostic `TimelineManager` plain class holding mutable geometry + a small store (Zustand, matching existing `galleryStore`) for the few reactive scalars the UI reads (`months`, `scrubberMonths`, `scrollTop`, `visibleWindow`, selection). Keep heavy geometry off React state; expose an imperative `subscribe`/snapshot so the grid reads positions without re-rendering every tile.
-
-Algorithms to reimplement (these are the load-bearing pieces)
-- Justified row packing from aspect ratios → box geometry. Use the `justified-layout` npm package (same one reference falls back to) rather than the WASM module to start; WASM is an optimization, not required. `layout-utils.ts:102-116`.
-- Two-level month→day-group flow + month height accumulation. `layout-support.svelte.ts:23-70`.
-- Pre-load height estimate formula (so the scrubber exists before fetch). `layout-support.svelte.ts:10-19`.
-- Viewport-proximity intersection with top/bottom expansion + deferred layout clearing. `intersection-support.svelte.ts:31-55`.
-- Scrubber segment building (sparse label/dot placement). `Scrubber.svelte:144-192`.
-- Bidirectional scroll↔scrubber mapping (segment+percent → scrollTop and viewport-top-month detection). `Timeline.svelte:268-363`, `Scrubber.svelte:95-129`.
-
-Virtualization approach
-- Own the scroll container (don't ride `window`). Absolute-position month sections at their cumulative `top`; within a section, absolute-position rows/tiles from box geometry. Render only In/Near months. Spacer-free since each section has an explicit height.
-- React 19 / Next 16: this is a client component (`"use client"`). Avoid `ResizeObserver`-driven full re-layouts on every scroll; throttle layout to width changes, and only recompute proximities (cheap) on scroll. The current `virtualized-grid.tsx`'s reliance on `getBoundingClientRect` per scroll should be replaced by reading the owned container's `scrollTop`.
-- thumbhash/blur placeholders need a decode util on the React side (or fall back to a CSS blur of `thumbnail_url`) if the backend can't emit thumbhash soon.
-
-Backend dependency
-- The month-bucket count endpoint and per-bucket asset endpoint must land in `lib/api.ts` + the Go/Python backend before the timeline can be built. This is the long pole and should be specced with the API/backend lane. Without it, only a degraded "estimate from total count" timeline is possible.
-
----
-
-## 5. Effort estimate (S/M/L)
-
-| Behavior | Size | Notes |
-|---|---|---|
-| Month-bucket count endpoint + per-bucket asset endpoint (backend + api.ts) | **L** | Blocking dependency; new data model + backend work |
-| Justified layout (ratios → box geometry) | **M** | Use `justified-layout` lib; ratio from `width/height` |
-| Two-level month→day-group flow + height accumulation | **M** | Direct port of layout-support |
-| Pre-load height estimation | **S** | One formula |
-| Owned-container virtualization + proximity tiers + deferred layout | **L** | Core rewrite of `virtualized-grid.tsx`; variable section heights |
-| Lazy per-month fetch with cancellable off-screen loads | **M** | Ties windowing to data layer |
-| Date scrubber: segments + sparse labels/dots | **M** | Port `calculateSegments` |
-| Scrubber ↔ scroll two-way sync (incl. limitedScroll edge case) | **M** | Fiddly mapping math |
-| Hover-date preview + mobile/desktop width behavior | **S** | UI polish on the scrubber |
-| Month/day headers + grouping | **S–M** | New render structure |
-| Multi-select range/shift + whole-group select | **M** | Confirm against Find's existing selection first |
-| thumbhash blur placeholders | **S** | Or CSS-blur fallback if no backend support |
-| Realtime websocket upsert into months | **M** | Only if Find has/plans websockets; otherwise defer |
diff --git a/docs/overhaul/inventory/lane-b-albums-sharing.md b/docs/overhaul/inventory/lane-b-albums-sharing.md
deleted file mode 100644
index 712696d5..00000000
--- a/docs/overhaul/inventory/lane-b-albums-sharing.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# Lane B Inventory — Albums & Sharing
-
-Read-only discovery from `reference-app/` (Svelte web + NestJS server). Behavior/data-model/API-shape only; no verbatim source. Find currently has **no** albums or sharing primitives.
-
----
-
-## 1. Album behaviors
-
-- **Create** — album has a name (default `"Untitled Album"`), optional description, optional initial users (with roles) and initial asset IDs. Creating user becomes `owner` (enforced via a unique index allowing exactly one `owner` row per album). `reference-app/server/src/controllers/album.controller.ts:46`, `dtos/album.dto.ts:32` (CreateAlbumSchema), `schema/tables/album-user.table.ts:23` (`album_user_unique_owner`).
-- **List / get** — list all albums available to user, with filters: `id`, `name` (exact), `isOwned` (owned vs shared-with-me), `isShared`, `assetId` (albums containing an asset). Single-album get is also reachable via a shared link. `album.controller.ts:33,60`, `dtos/album.dto.ts:66` (GetAlbumsSchema).
-- **Update (metadata only)** — name, description, **cover** (`albumThumbnailAssetId`), `isActivityEnabled`, and `order` (asset sort). Explicitly not for adding/removing assets or users. `album.controller.ts:71`, `dtos/album.dto.ts:56` (UpdateAlbumSchema).
-- **Delete** — soft-delete (trashed via `deletedAt`) then background hard-delete. `album.controller.ts:88`, `schema/tables/album.table.ts:41` (`deletedAt`).
-- **Membership (assets)** — add assets to one album; add assets to many albums at once (`PUT /albums/assets` with album+asset ID lists); remove assets. Bulk responses report per-asset success/error (duplicate / no-permission). `album.controller.ts:111,126,138`, `dtos/album.dto.ts:42` (AlbumsAddAssetsSchema).
-- **Membership (users) / roles** — share album with users at a role; update a user's role; remove a user (use `me` to leave a shared album). `album.controller.ts:152,170,189`. Roles: `owner`, `editor`, `viewer` — `enum.ts:65`.
-- **Cover** — single nullable thumbnail asset FK, `ON DELETE SET NULL`. `schema/tables/album.table.ts:25`.
-- **Ordering** — album-level `order` enum (asc/desc by date); response derives `startDate`/`endDate` from member assets. `schema/tables/album.table.ts:48`, `dtos/album.dto.ts:175` (mapAlbum).
-- **Statistics** — counts of owned / shared / not-shared albums. `album.controller.ts:54`.
-- **Map markers** — per-album geo markers (shared-link accessible). `album.controller.ts:104`.
-- **Activity feed** — likes + comments on an album or on an asset-in-album, toggled by `isActivityEnabled`. Like is unique per (asset,user,album); a row is either a comment or a like (DB check constraint). Create/list/delete/statistics. `controllers/activity.controller.ts:30,46,66,78`, `schema/tables/activity.table.ts:24,28`.
-- **Response shape** — `albumUsers` ordered owner-first then auth-user then alphabetical; flags `shared`, `hasSharedLink`; `assetCount`. `dtos/album.dto.ts:108` (AlbumResponseSchema).
-
-## 2. Sharing behaviors
-
-### Shared links — `controllers/shared-link.controller.ts`, `services/shared-link.service.ts`, `dtos/shared-link.dto.ts`
-- **Two types** — `ALBUM` (shares a whole album) and `INDIVIDUAL` (a set of assets not necessarily in an album). `enum.ts:312`.
-- **Create** requires access check on the target: `AlbumShare` permission for album links, `AssetShare` for individual. A random 50-byte `key` is generated per link (base64url in responses). `shared-link.service.ts:68,93`.
-- **Per-link options** — `description`, `password` (optional), custom `slug` (unique), `expiresAt` (nullable), `allowUpload` (default false on table / true in create path), `allowDownload` (default true), `showMetadata`/`showExif` (default true). Rule: if `showMetadata=false` then `allowDownload` is forced false. `dtos/shared-link.dto.ts:21`, `shared-link.service.ts:97`.
-- **Update / delete** — owner-scoped edit of the same fields; delete. `shared-link.controller.ts:120,140`.
-- **Individual-link asset membership** — add/remove assets (only for `INDIVIDUAL` type), with per-asset access check and duplicate/not-found reporting. `shared-link.controller.ts:155,177`, `shared-link.service.ts:148,191`.
-- **Public access path** — link is consumed via a `key` query param (and/or `slug`) carried as headers `x-immich-share-key` / `x-immich-share-slug`; auth guard resolves it to a `sharedLink` auth context. Endpoints opt in with `sharedLink: true` (e.g. album get, map markers, asset thumbnail). `enum.ts:23,32`, `album.controller.ts:60`.
-- **Password flow** — for password-protected links: `POST /shared-links/login` checks the password, then issues a token stored in cookie `immich_shared_link_token` (comma-joined to support multiple links). `GET /shared-links/me` returns the current link, rejecting if password set and token absent. Token = `sha256(id-password)` base64. `shared-link.service.ts:26,46`, `controllers/shared-link.controller.ts:70` (login), `:92` (me).
-- **Metadata stripping** — when `showExif=false`, asset metadata is stripped from responses; OpenGraph preview tags are suppressed for password-protected links. `shared-link.service.ts:60,209`.
-- **List / search** — list all of a user's links, filter by `albumId` or `id`. `shared-link.controller.ts:62`.
-
-### Partner sharing — `controllers/partner.controller.ts`, `dtos/partner.dto.ts`
-- **Model** — directional: a user (`sharedById`) shares their whole library with another user (`sharedWithId`); PK is the pair. `schema/tables/partner.table.ts:25`.
-- **Create / remove** — start/stop sharing with a partner (by `sharedWithId`). A deprecated `POST /partners/:id` variant exists. `partner.controller.ts:36,67`.
-- **Update** — `inTimeline` toggle: whether the partner's assets appear in the user's main timeline. `partner.controller.ts:55`, `dtos/partner.dto.ts:16`.
-- **List** — by `direction` (shared-by-me vs shared-with-me). `partner.controller.ts:23`, `dtos/partner.dto.ts:21`.
-- **Access** — partner-shared assets are readable by the partner via an access-repo join (`partner.sharedWithId = userId`). `repositories/access.repository.ts:201`.
-
-## 3. Data model (abstract)
-
-| Table | Key columns | Notes |
-|---|---|---|
-| `album` | id, albumName (default), description, albumThumbnailAssetId (FK asset, SET NULL), order (enum), isActivityEnabled (bool), createdAt, updatedAt, deletedAt (soft delete) | cover + ordering live here. `album.table.ts` |
-| `album_asset` | (albumId, assetId) composite PK, createdAt | membership join; CASCADE on both FKs. `album-asset.table.ts` |
-| `album_user` | (albumId, userId) composite PK, role enum (owner/editor/viewer) | unique partial index: one `owner` per album. `album-user.table.ts` |
-| `shared_link` | id, userId (FK), key (bytea, unique), type (ALBUM/INDIVIDUAL), albumId (nullable FK), description, password (nullable), slug (nullable unique), expiresAt (nullable), allowUpload, allowDownload, showExif | one row per link. `shared-link.table.ts` |
-| `shared_link_asset` | (assetId, sharedLinkId) composite PK | for INDIVIDUAL links. `shared-link-asset.table.ts` |
-| `partner` | (sharedById, sharedWithId) composite PK, inTimeline (bool), createdAt | directional library share. `partner.table.ts` |
-| `activity` | id, albumId (FK), userId (FK), assetId (nullable FK), comment (nullable), isLiked (bool) | like XOR comment (check constraint); unique like per (asset,user,album). `activity.table.ts` |
-
-(Reference also keeps `*_audit` tables for sync; not needed for parity.)
-
-## 4. API surface
-
-Albums (`/albums`):
-- `GET /albums` (list, filters) · `POST /albums` (create) · `GET /albums/statistics` · `GET /albums/:id` (sharedLink-accessible) · `PATCH /albums/:id` (metadata/cover/order) · `DELETE /albums/:id`
-- `GET /albums/:id/map-markers` · `PUT /albums/:id/assets` (add) · `PUT /albums/assets` (multi-album add) · `DELETE /albums/:id/assets` (remove)
-- `PUT /albums/:id/users` (share) · `PUT /albums/:id/user/:userId` (role) · `DELETE /albums/:id/user/:userId` (remove/leave)
-
-Shared links (`/shared-links`):
-- `GET /shared-links` (list) · `POST /shared-links` (create) · `GET /shared-links/:id` · `PATCH /shared-links/:id` · `DELETE /shared-links/:id`
-- `POST /shared-links/login` (password) · `GET /shared-links/me` (current link) · `PUT /shared-links/:id/assets` · `DELETE /shared-links/:id/assets`
-
-Partners (`/partners`):
-- `GET /partners?direction=` · `POST /partners` · `POST /partners/:id` (deprecated) · `PUT /partners/:id` (inTimeline) · `DELETE /partners/:id`
-
-Activities (`/activities`):
-- `GET /activities` (by album/asset) · `POST /activities` (like/comment) · `GET /activities/statistics` · `DELETE /activities/:id`
-
-## 5. Find today + GAP
-
-Find routers present: auth, cluster(s), config, duplicates, feedback, gallery, people, search, status, upload, vault. Models: cluster, face, feedback, invite, join_request, media, person, session, user, vault. **No album, album_asset, album_user, shared_link, partner, or activity** — entire lane is greenfield.
-
-- `invite.py` (`InviteToken`) and `join_request.py` (`JoinRequest`) are **instance-onboarding**, not content sharing: an admin mints a single-use, SHA-256-hashed invite token; a prospective user submits a join request (username + bcrypt-style password hash + optional invite FK + pending/approved status) for admin approval. They share *nothing* with the album/shared-link/partner model and should not be conflated. (`backend/src/find_api/models/invite.py`, `join_request.py`.)
-- **GAP — everything**: album CRUD + membership + roles + cover + ordering; shared links (album & individual, password/expiry/slug/upload/download/metadata flags + public key/slug access); partner sharing; optional activity feed. Permission model and per-resource access checks must be built; Find has only user/session auth today.
-
-## 6. Security notes
-
-- **Shared-link password is stored and compared in plaintext** in the reference (`password !== dto.password`, `shared-link.service.ts:38`). **Do not copy this.** Find should hash share-link passwords (the codebase already hashes invite tokens with SHA-256 and join-request passwords — reuse that discipline). At minimum constant-time compare; preferably a slow hash.
-- **Capability key** — the random 50-byte `key` is the bearer capability for public access; it must be unguessable (CSPRNG), unique-indexed, and never logged. Slugs are public/guessable, so slug access must still resolve to the same scoped permissions, not bypass them.
-- **Access scoping** — every shared-link/album read must be scoped to exactly the linked album/assets; the reference enforces this via an access repository join layer (`access.repository.ts`). Public (shared-link) auth must never widen to the owner's whole library. Album endpoints that accept `sharedLink: true` must strip metadata when `showExif=false` and block download when `allowDownload=false`.
-- **Permission enforcement** — reference uses fine-grained permissions (`album.*`, `albumAsset.*`, `albumUser.*`, `sharedLink.*`, `partner.*`, `AlbumShare`, `AssetShare`). Find needs an equivalent owner/editor/viewer check on every album mutation and an ownership check on every shared-link mutation.
-- **Expiry** — `expiresAt` must be enforced server-side on every access, not just hidden in UI.
-- **Partner direction** — partner access is one-directional; ensure `sharedWithId` can read but not mutate the sharer's assets.
-
-## 7. Effort estimate
-
-| Feature | Size | Rationale |
-|---|---|---|
-| Album CRUD + cover + ordering | **M** | 2 tables, soft delete, metadata update |
-| Album asset membership (incl. bulk/multi-album) | **M** | join table + per-asset result reporting |
-| Album user roles / sharing | **M** | role enum, owner-uniqueness, access checks |
-| Shared links (album + individual, all flags) | **L** | key gen, slug uniqueness, password hashing, public access path, metadata stripping, expiry, asset membership |
-| Partner sharing | **S–M** | one table, directional access join, timeline toggle |
-| Activity feed (likes/comments) | **M** | constraints, statistics, optional for v1 |
-| Permission/access-control layer (shared dep) | **L** | cross-cutting; gates all of the above |
diff --git a/docs/overhaul/inventory/lane-c-archive-favorites-trash.md b/docs/overhaul/inventory/lane-c-archive-favorites-trash.md
deleted file mode 100644
index f0103f2f..00000000
--- a/docs/overhaul/inventory/lane-c-archive-favorites-trash.md
+++ /dev/null
@@ -1,106 +0,0 @@
-# Lane C — Archive, Favorites, Trash (and Restore)
-
-Read-only discovery. Reference = `reference-app/` (NestJS server). Target = Find (`backend/src/find_api/`).
-Reference behavior/state-model/scoping extracted; no source copied.
-
----
-
-## 1. State model
-
-The reference asset carries **three orthogonal flags**, not one. Archive and Trash are NOT booleans — they are values on two enums; only Favorite is a boolean.
-
-| Concept | Column | Type | Default | Reference |
-|---|---|---|---|---|
-| Favorite | `isFavorite` | boolean | false | `reference-app/server/src/schema/tables/asset.table.ts:83-84` |
-| Archive / Timeline visibility | `visibility` | enum `asset_visibility_enum` | `timeline` | `asset.table.ts:137-138` |
-| Soft-delete timestamp | `deletedAt` | timestamptz, nullable | null | `asset.table.ts:119-120` |
-| Lifecycle status | `status` | enum `assets_status_enum` | `active` | `asset.table.ts:131-132` |
-
-Enum values:
-- `AssetVisibility` = `archive` | `timeline` | `hidden` | `locked` — `reference-app/server/src/enum.ts:1137-1145`. **Archive is a visibility value**, not a separate flag. `timeline` = shows in main grid; `archive` = archived (hidden from timeline, still owned/searchable in archive view).
-- `AssetStatus` = `active` | `trashed` | `deleted` — `reference-app/server/src/enum.ts:390-393`. `trashed` = in trash (recoverable); `deleted` = marked for permanent purge.
-
-Key insight: **trash uses two coordinated fields** — `status` (active/trashed/deleted) AND `deletedAt` (timestamp). `deletedAt IS NOT NULL` means "in trash"; `status` distinguishes recoverable trash (`trashed`) from queued-for-purge (`deleted`). The `deletedAt` timestamp drives auto-purge timing.
-
-A partial index optimizes the hot path: `visibility = 'timeline' AND "deletedAt" IS NULL` — `asset.table.ts:58-62`.
-
----
-
-## 2. Transitions
-
-All bulk mutations flow through `updateAll` / `deleteAll`.
-
-- **Favorite / Unfavorite** — set `isFavorite` via bulk update. `AssetService.updateAll` extracts `isFavorite` from DTO and persists it (`reference-app/server/src/services/asset.service.ts:130-174`, esp. `:133, :146, :172-173`).
-- **Archive / Unarchive** — set `visibility` to `archive` (archive) or `timeline` (unarchive) via the same `updateAll` path (`asset.service.ts:134, :146, :172-173`). Setting `visibility=locked` additionally strips the asset from all albums (`asset.service.ts:176-178`).
-- **Trash (soft delete)** — `deleteAll` with `force=false`: sets `deletedAt = now()` and `status = trashed`, then emits `AssetTrashAll` (`asset.service.ts:370-382`, esp. `:374-377`).
-- **Permanent delete (skip trash)** — `deleteAll` with `force=true`: sets `deletedAt = now()` and `status = deleted`, emits `AssetDeleteAll` (`asset.service.ts:374-378`).
-- **Restore (single/bulk)** — set `status = active`, `deletedAt = null` for given ids where `status = trashed` (`reference-app/server/src/repositories/trash.repository.ts:38-52`; service `reference-app/server/src/services/trash.service.ts:12-25`).
-- **Restore all** — same, scoped to one owner where `status = trashed` (`trash.repository.ts:14-24`; `trash.service.ts:27-33`).
-- **Empty trash** — flips all owner's `trashed` rows to `status = deleted` (`trash.repository.ts:26-36`), then queues the `AssetEmptyTrash` background job which streams every `status = deleted` id and queues real `AssetDelete` (disk + DB) jobs (`trash.service.ts:35-84`, esp. getDeletedIds `trash.repository.ts:10-12`).
-- **Auto-purge after N days** — `AssetDeleteCheck` background job: computes `trashedBefore = now() − config.trash.days` (disabled ⇒ 0 days), streams assets with `deletedAt <= trashedBefore`, queues `AssetDelete` (`asset.service.ts:273-305`, esp. `:276-279, :294`; query `reference-app/server/src/repositories/asset-job.repository.ts:410-414` filters `asset.deletedAt <= trashedBefore`). `AssetDelete` does the irreversible file+row removal (`asset.service.ts:307+`).
-
----
-
-## 3. Query scoping — THE KEY PART
-
-Two independent predicates gate almost every "normal" asset query:
-1. **`deletedAt IS NULL`** — excludes anything in trash (trashed or queued-for-delete).
-2. **`visibility IN ('timeline','archive')`** — excludes `hidden` and `locked`; archived assets are NOT excluded by default visibility.
-
-The default-visibility helper: `withDefaultVisibility` = `where visibility IN ('archive','timeline')` (`reference-app/server/src/utils/database.ts:82-84`).
-
-**Main timeline** (the photo grid): `visibility = 'timeline' AND deletedAt IS NULL` — archived assets ARE excluded here because timeline requires `= 'timeline'` exactly (`asset.repository.ts:481, :490`; bucket builder `:967-972, :985-987`; search repo timeline `reference-app/server/src/repositories/search.repository.ts:388-390, :404-406, :499-500`).
-
-**Archive view**: `visibility = 'archive' AND deletedAt IS NULL` (timeline query parameterized by `visibility`, `asset.repository.ts:767, :842`).
-
-**Favorites view**: `isFavorite = true` plus the standard `deletedAt IS NULL` and a visibility filter (`asset.repository.ts:782, :868`).
-
-**Trash view**: inverts the trash predicate — `deletedAt IS NOT NULL AND status != 'deleted'` (recoverable trash only, excludes purge-queued rows) (`asset.repository.ts:712-713, :750-751, :840, :899`).
-
-**Search** scoping (`search.repository.ts`): options carry `visibility`, `isFavorite`, `withDeleted` (`:27, :32-35`). Search applies `visibility = 'timeline'` + `deletedAt IS NULL` by default, with `isFavorite` available as a filter (`:388-390, :191-275, :499-500`).
-
-**Statistics** mirror the same gates with toggles: `withDefaultVisibility` when no explicit visibility, optional `isFavorite`, and trash inversion (`asset.repository.ts:701-714`).
-
-Summary scoping rule: a row is "in the main timeline" iff **`visibility = 'timeline'` AND `deletedAt IS NULL`**. Archive flips visibility to `'archive'`; trash sets `deletedAt`; both naturally drop the row from the timeline grid.
-
----
-
-## 4. API surface
-
-Trash controller — `reference-app/server/src/controllers/trash.controller.ts`:
-- `POST /trash/empty` — permanently delete all trashed items (queues purge) (`:16-25`).
-- `POST /trash/restore` — restore all trashed items for the user (`:28-37`).
-- `POST /trash/restore/assets` — restore specific asset ids from trash (body = BulkIdsDto) (`:40-49`).
-
-Asset controller — `reference-app/server/src/controllers/asset.controller.ts`:
-- `PUT /assets` — bulk update; carries `isFavorite` and `visibility` (archive/unarchive, favorite/unfavorite) (`:56-70`).
-- `PATCH /assets` — same handler, v3 (`:72-78`).
-- `DELETE /assets` — bulk soft-delete (trash) or `force` permanent delete (`:80-90`).
-- `PUT /assets/:id` — single-asset update (favorite/visibility) (`:141`).
-
-(Background jobs, not HTTP: `AssetEmptyTrash`, `AssetDeleteCheck`, `AssetDelete`.)
-
----
-
-## 5. Find today + GAP
-
-**`backend/src/find_api/models/media.py`** has:
-- `liked` boolean (default false) — `media.py:39-41`. This is Find's favorite equivalent (already exists).
-- `is_hidden` boolean + `hidden_at` + `vault_state` (`visible`/...) + `encrypted_at` — `media.py:43-54`. This is the vault/hidden system (Lane B's concern), NOT archive/trash.
-- `status` String = `pending|processing|indexed|failed` — `media.py:57-58`. **This is processing status, a totally different axis** from reference's `active/trashed/deleted` lifecycle. Cannot be reused for trash.
-- **No `is_archived`, no `deleted_at`/`trashed_at`, no archive/trash lifecycle.** Confirmed: Find has NO trash, NO archive today. Favorites exist as `liked`.
-
-**`backend/src/find_api/routers/gallery.py` scoping today**: gallery list filters only `Media.is_hidden.is_(False)` then applies `scope_media_query` (ownership/IDOR) + optional `status`/`liked`/metadata/date filters (`gallery.py:194, :355-358; core/dependencies.py:75-87`). `liked` is an optional filter, not a separate view. Search scopes by `status='indexed' AND vector IS NOT NULL AND is_hidden = false` (`routers/search.py:128, :262, :311`).
-
-**GAP:**
-- Add `is_archived` (boolean, default false, indexed) and `deleted_at` (timestamptz nullable, indexed) to `media.py`. Optionally a `trash` lifecycle marker, but `deleted_at IS NULL/NOT NULL` plus an "empty-trash purge" pass is sufficient for a single-user app (status enum can be deferred — YAGNI). Favorites already covered by `liked`.
-- Gallery base query must add `Media.deleted_at.is_(None)` (exclude trash) AND `Media.is_archived.is_(False)` (exclude archive) to the existing `is_hidden == False` gate.
-- New views: archive = `is_archived == True, deleted_at IS NULL`; trash = `deleted_at IS NOT NULL`; favorites = existing `liked == True` (+ default scoping).
-- Search query must also gain `deleted_at IS NULL` and `is_archived = false` (currently filters only `is_hidden`).
-- New endpoints: trash/restore/empty + archive toggle + (existing) like toggle. Add an auto-purge job (delete rows where `deleted_at <= now − N days`).
-
----
-
-## 6. Effort estimate
-
-**M (Medium).** Two new columns + Alembic migration + small backfill. Scoping changes are mechanical but touch every list/search/count query (gallery, search, stats, duplicates, people) — the risk is missing a query and leaking trashed/archived rows. New trash/archive/restore endpoints + one auto-purge background task. Favorites is already done (`liked`). No new infra. Bulk of effort is auditing every existing query for the two new predicates and adding the purge job.
diff --git a/docs/overhaul/inventory/lane-d-viewer-slideshow.md b/docs/overhaul/inventory/lane-d-viewer-slideshow.md
deleted file mode 100644
index 78746129..00000000
--- a/docs/overhaul/inventory/lane-d-viewer-slideshow.md
+++ /dev/null
@@ -1,183 +0,0 @@
-# Lane D — Asset Viewer & Slideshow Inventory
-
-Read-only behavioral extraction from the reference app (Immich-style Svelte 5 web) compared against Find's current `image-preview-modal.tsx`. Reference paths are relative to `reference-app/web/src/lib/`. No source copied — behaviors only.
-
-Key reference files:
-- `components/asset-viewer/AssetViewer.svelte` — shell: layout, navigation, slideshow orchestration, stack, preload wiring.
-- `components/asset-viewer/PhotoViewer.svelte` — the photo surface: zoom action, swipe, dblclick, keyboard (z/s/copy), face/OCR overlays, cast hook.
-- `components/AdaptiveImage.svelte` — progressive thumbnail→preview→original rendering + GPU raster sizing.
-- `utils/adaptive-image-loader.svelte.ts` — `AdaptiveImageLoader` quality-chain state machine.
-- `components/asset-viewer/PreloadManager.svelte.ts` — neighbor preloading.
-- `actions/zoom-image.ts` — wheel/pinch zoom via `@zoom-image/core`.
-- `managers/asset-viewer-manager.svelte.ts` — zoom state, animated zoom, panel state.
-- `components/asset-viewer/SlideshowBar.svelte` — slideshow controls, progress bar, auto-hide.
-- `stores/slideshow.store.ts` — persisted slideshow settings + state machine.
-- `utils/slideshow-history.ts` — shuffle history (back/forward).
-- `components/asset-viewer/SlideshowMetadataOverlay.svelte` — slideshow caption/date/location overlay.
-- `managers/cast-manager.svelte.ts` — Google Cast (note only).
-
----
-
-## 1. Viewer behaviors
-
-### Open / close
-- Viewer is a full-screen fixed `` grid (`AssetViewer.svelte:488-493`), `bg-black`, with `focusTrap` action and `contain: layout`.
-- On open: adds `asset-viewer-open` class to `document.body` (`AssetViewer.svelte:152-153, 373-377`); on destroy resets activity, panel state, removes the class, and tears down preloaders (`:177-182`).
-- Close: `closeViewer()` calls `onClose?.(asset.id)` (`:184-186`). Bound to `Escape` and to the back-arrow button in the nav bar (`AssetViewerNavBar.svelte:78` — `shortcuts: [{ key: 'Escape' }]`).
-
-### Next / previous navigation
-- `navigateAsset(order?)` is the single nav entry point (`AssetViewer.svelte:198-250`). Guards re-entrancy with an `InvocationTracker` (`:197, 209-213`) so rapid presses don't stack.
-- Calls `preloadManager.cancelBeforeNavigation(order)` first (`:207`) to drop the now-irrelevant neighbor preload.
-- Non-slideshow: forward = `navigateToAsset(cursor.nextAsset)`, back = `cursor.previousAsset` (`:229-231`).
-- Prev/next on-screen affordances are `PreviousAssetAction` / `NextAssetAction` components, only shown when `showNavigation`, not editing, and a neighbor exists (`:526-530, 604-608`).
-- Stack navigation: ArrowUp/ArrowDown move within an asset stack (`:252-266, 483-485`).
-
-### Zoom
-- Wheel/pinch zoom is the `zoomImageAction` (`actions/zoom-image.ts:11-16`) using `@zoom-image/core` `createZoomImageWheel`, `maxZoom: 10`, seeded from `assetViewerManager.zoomState`. Applied on the photo container (`PhotoViewer.svelte:220`).
-- Double-click / double-tap toggles zoom between 1x and 2x via `onZoom` → `assetViewerManager.animatedZoom(targetZoom)` (`PhotoViewer.svelte:103-106, 219`); `animatedZoom` is a 300ms `cubicOut` rAF tween (`asset-viewer-manager.svelte.ts:143-161`).
-- Keyboard `z` also toggles zoom (`PhotoViewer.svelte:206`).
-- Synthetic dblclick from touch double-tap is suppressed so it doesn't double-fire with the zoom lib (`zoom-image.ts:103-125`).
-- Zoom state resets on every asset change (`PhotoViewer.svelte:50-54`, `AdaptiveImage.svelte:138-142`).
-
-### Pan
-- Pan is handled internally by `@zoom-image/core` (position tracked as `currentPositionX/Y` in `zoomState`, `asset-viewer-manager.svelte.ts:24-30`). No separate pan code — pointer drag while zoomed pans.
-- `node.style.touchAction = 'none'` set so the browser doesn't intercept pan/pinch (`zoom-image.ts:128`).
-- Pointerdown cancels any running zoom animation so a drag feels immediate (`zoom-image.ts:26`).
-
-### Keyboard shortcuts (each key)
-| Key | Action | Source |
-|-----|--------|--------|
-| `Escape` | Close viewer (or exit slideshow when in slideshow) | `AssetViewerNavBar.svelte:78`; `SlideshowBar.svelte:144` |
-| `ArrowLeft` | Previous asset (slideshow bar binds it too) | `SlideshowBar.svelte:145`; viewer prev via affordance |
-| `ArrowRight` | Next asset | `SlideshowBar.svelte:146` |
-| `ArrowUp` | Previous in stack | `AssetViewer.svelte:483` |
-| `ArrowDown` | Next in stack | `AssetViewer.svelte:484` |
-| `z` | Toggle zoom (1x ↔ 2x) | `PhotoViewer.svelte:206` |
-| `s` | Start slideshow | `PhotoViewer.svelte:207` |
-| `Ctrl/Cmd + c` | Copy image to clipboard (skipped if a text range is selected) | `PhotoViewer.svelte:208-209, 117-124` |
-| `Space` | Pause/resume slideshow progress (photos only; videos keep native space) | `SlideshowBar.svelte:150-162` |
-- Shortcuts are registered via a `use:shortcuts` action bound to `svelte:document` (`PhotoViewer.svelte:204-211`, `AssetViewer.svelte:480-486`).
-
-### Swipe / touch
-- `useSwipe` from `svelte-gestures` wraps the photo container (`PhotoViewer.svelte:23, 221`).
-- `onSwipe` (`AssetViewer.svelte:458-474`): swipe left → next, swipe right → previous. Suppressed when `zoom > 1` (so panning a zoomed image doesn't navigate) and when an OCR overlay is active.
-- Slideshow bar has its own swipe: `pan-x` touch action, `onswipedown` re-shows the control bar (`SlideshowBar.svelte:135-140, 171`).
-
-### Thumbnail → full-res progressive load
-- `AdaptiveImage.svelte` stacks up to three `` elements (thumbnail, preview, original) and shows/hides them by load state (`:251-286`).
-- Order: thumbhash placeholder (instant, from `asset.thumbhash`) → thumbnail → preview → original. The thumbhash blur shows until any real layer succeeds (`:185-196`).
-- `afterThumbnail` decides the next fetch: if `zoom > 1` jump straight to `original`, else fetch `preview` (`AdaptiveImage.svelte:96-102`). Preview error falls back to original (`:116`).
-- Original is only triggered lazily — when the user zooms past 1x (`AdaptiveImage.svelte:202-206`), not on initial display. This is the core loading-discipline rule.
-
----
-
-## 2. Slideshow behaviors
-
-### Start / stop
-- Start: pressing `s` or the nav action sets `slideshowState = PlaySlideshow` (`PhotoViewer.svelte:114, 207`). A subscription in `AssetViewer.onMount` resets shuffle history, queues the current asset, and runs `handlePlaySlideshow()` (`AssetViewer.svelte:154-162`).
-- `handlePlaySlideshow` records `slideshowStartAssetId` and requests fullscreen on the viewer element; failure to enter fullscreen aborts to `StopSlideshow` (`:284-292`).
-- Stop: `StopSlideshow` state → `handleStopSlideshow()` exits fullscreen and resets state to `None` (`:294-305`). Exiting fullscreen via the browser also stops the slideshow (`SlideshowBar.svelte:115-133`).
-
-### Interval / progress / autoplay
-- A `ProgressBar` drives advancement; `onDone` → `handleDone()` advances next (or previous for ascending order) (`SlideshowBar.svelte:98-106, 237-246`).
-- Duration is `slideshowDelay` (default 5s), with `slideshowAutoplay` (default true) and `showProgressBar` (default true) (`slideshow.store.ts:44-47`, bound `SlideshowBar.svelte:238-244`).
-- `Space` toggles pause/play of the progress bar (`SlideshowBar.svelte:150-162`).
-- On each successful navigation while playing, `restartProgress` is fired to reset the bar (`AssetViewer.svelte:237-240, 275`).
-- Video slides: the progress bar is hidden and the native video element handles playback; `handleVideoStarted` stops progress (`AssetViewer.svelte:278-282`, `SlideshowBar.svelte:42, 190-199, 237`).
-- Controls auto-hide after 2.5s of no mouse movement and hide the cursor; any mousemove re-shows them (`SlideshowBar.svelte:51-69, 168`).
-
-### Transitions / look
-- `slideshowTransition` (default true) toggles fly transitions on the control bar (`slideshow.store.ts:46`; `fly` used `SlideshowBar.svelte:178`).
-- `SlideshowLook` (`slideshow.store.ts:16-31`): `Contain` (default), `Cover` (object-cover), `BlurredBackground` (object-contain over a blurred thumbhash backdrop). Look feeds `objectFit` and the blurred backdrop in `PhotoViewer.svelte:152-154, 227, 240-244`.
-
-### Shuffle / repeat / order
-- `SlideshowNavigation` (`slideshow.store.ts:10-14`): `Shuffle`, `AscendingOrder` (backward), `DescendingOrder` (forward, default).
-- Shuffle uses `SlideshowHistory` (`utils/slideshow-history.ts`) — a back/forward stack so previous re-walks visited assets; when it runs out it calls `onRandom?.()` to pull a new random asset and queues it (`AssetViewer.svelte:214-227`, `slideshow-history.ts:21-39`).
-- `slideshowRepeat` (default false): when navigation exhausts the list, jumps back to `slideshowStartAssetId` and restarts; otherwise stops (`AssetViewer.svelte:242-248`).
-
-### Slideshow settings & metadata overlay
-- All settings live in `SlideshowSettingsModal.svelte` (delay, progress bar, navigation, look, transition, autoplay, repeat, metadata overlay + mode), all persisted to localStorage via `svelte-persisted-store` (`slideshow.store.ts:37-53`, modal `:42-50, 85-88`).
-- Optional metadata overlay (`SlideshowMetadataOverlay.svelte`): two modes — `DescriptionOnly` or `Full` (description + date + city/state/country) (`:18-35`).
-
----
-
-## 3. Loading discipline
-
-Quality chain is thumbnail → preview → original, modeled by `AdaptiveImageLoader` (`utils/adaptive-image-loader.svelte.ts:34-126`).
-
-Rules:
-1. **Thumbhash first.** `asset.thumbhash` renders an instant blurred placeholder with zero network (`AdaptiveImage.svelte:242-249`).
-2. **Thumbnail is the entry fetch**, then `onAfterLoad`/`onAfterError` chains to the next quality (`adaptive-image-loader` quality configs; `AdaptiveImage.svelte:104-121`).
-3. **Preview is the default display quality** at zoom = 1 (`afterThumbnail` triggers `preview`, `AdaptiveImage.svelte:96-102`).
-4. **Original is deferred** — only fetched when the user zooms past 1x (`AdaptiveImage.svelte:202-206`, `PhotoViewer.svelte` zoom path), or when preview fails (`:116`). Avoids paying full-res bytes for casual browsing.
-5. **Error fallbacks cascade**: thumbnail error → still try preview; preview error → original; original error → BrokenAsset (`AdaptiveImage.svelte:185-196, 289-291`).
-
-Neighbor preloading (`PreloadManager.svelte.ts`):
-- On viewer mount, preloads both `previousAsset` and `nextAsset` to thumbnail+preview (not original) (`:89-96, 23-42`).
-- After each navigation, `updateAfterNavigation` keeps the preloader heading the same direction and discards the stale one; `cancelBeforeNavigation` aborts the opposite-direction preload before moving (`:54-87`, called `AssetViewer.svelte:207, 400-412`).
-- Preloaders are torn down on viewer destroy (`AssetViewer.svelte:181`).
-
-GPU/raster note (Chromium HDR seam workaround): `AdaptiveImage` sizes the image div near native resolution and counter-scales with `will-change: transform`, capping raster pixels by GPU `MAX_TEXTURE_SIZE` (`AdaptiveImage.svelte:1-49, 152-180`). Niche — low priority for Find port.
-
----
-
-## 4. Find today + GAP
-
-Find's `image-preview-modal.tsx` is a metadata-rich detail dialog, not a real viewer.
-
-What it does today:
-- Split layout: image stage on the left, a long metadata sidebar (file info, caption, objects, OCR, analysis stages, like/download/delete/reprocess) (`image-preview-modal.tsx:368-884`).
-- Single `` with `object-contain`, `unoptimized`, one resolution from `resolveMediaUrl` (`:387-410`). No thumbnail/preview/original tiering.
-- Keyboard: only `Escape` (close), `ArrowLeft`/`ArrowRight` (prev/next, gated on `hasPrevious`/`hasNext`) (`:345-366`).
-- Prev/next chevron buttons (`:412-437`). No swipe/touch.
-- Detail data via React Query with stale/refresh tuned to MinIO URL expiry, polling faster while pending/processing (`:236-248`).
-
-GAP vs reference:
-- **No zoom** (no `z`, no double-tap, no pinch/wheel). Biggest gap.
-- **No pan.**
-- **No progressive loading** — loads one resolution; no thumbhash/blur placeholder, no preview→original tiering, so large originals block the stage behind a spinner (`:388-391`).
-- **No neighbor preloading** — next/prev navigation re-fetches cold every time.
-- **No swipe/touch navigation.**
-- **No slideshow at all** — no play, interval, shuffle, repeat, transitions, fullscreen, or settings.
-- **No copy-to-clipboard of the image** (`Ctrl/Cmd+c`); Find only copies caption/OCR text.
-- **No fullscreen** mode for the image.
-- **No re-entrancy guard** on rapid arrow presses (reference uses `InvocationTracker`).
-- Find's media layer (`lib/media.ts`) only distinguishes a thumbnail endpoint (`/api/image/:id/thumbnail`) vs the full object URL — there is no "preview" (mid-res) tier, so a true thumbnail→preview→original chain needs a backend preview endpoint or an agreed two-tier (thumbnail→original) compromise.
-
----
-
-## 5. Port notes (React)
-
-- **Zoom/pan**: don't hand-roll. Use a maintained React lib:
- - `react-zoom-pan-pinch` (most popular; wheel+pinch+double-tap, `TransformWrapper`/`TransformComponent`, programmatic `zoomToElement`/reset) — closest drop-in for the reference's wheel+dblclick behavior.
- - Alternatives: `@use-gesture/react` + `@react-spring/web` for full control (matches the rAF-tweened `animatedZoom`), or wrap the same `@zoom-image/core` the reference uses (framework-agnostic) behind a small hook.
- - Recommendation: `react-zoom-pan-pinch` for speed; reset transform on asset change, gate swipe-nav on `scale === 1`.
-- **Keyboard**: a single `useEffect` window `keydown` listener already exists in Find (`:345-366`); extend it with `z` (zoom toggle), `s` (slideshow), and `Space` (slideshow pause). Keep `Escape` context-aware (exit slideshow before closing viewer). Consider `react-hotkeys-hook` if the map grows.
-- **Swipe**: `@use-gesture/react` `useDrag`/`useSwipe`, or `react-swipeable`. Mirror the "disabled when zoomed" rule.
-- **Progressive image**: render layered ` ` (or two stacked `next/image`) — thumbnail src shown immediately, swap/overlay preview on its `onLoad`, lazy-load original only on zoom. A thumbhash/blurhash placeholder needs a stored hash per asset (Find has none today — either add one or use the existing thumbnail endpoint as the blur source). Avoid `next/image` optimization here; the reference already serves pre-sized variants (`unoptimized` is already set in Find).
-- **Preloading**: on asset change, `new Image()` (or prefetch) the next/prev preview URLs; cancel via clearing `.src`. Port `PreloadManager`'s direction-aware keep/discard logic as a small class or hook.
-- **Slideshow**: a reducer/state machine (`none | playing | stopped`) + persisted settings (localStorage, e.g. `zustand` `persist` or a tiny `useLocalStorage`). Fullscreen via the Fullscreen API (`element.requestFullscreen()` + `fullscreenchange` listener, exactly as `SlideshowBar.svelte:115-133`). Progress bar = a CSS/rAF timer keyed to `slideshowDelay`.
-- **Casting**: reference uses Google Cast SDK via `cast-manager.svelte.ts` (`loadMedia(url)` when `isCasting`, casts the current preview URL — `PhotoViewer.svelte:128-150`). **Note only — out of scope for the initial Find port.** If pursued later, use the Cast Web Sender SDK and cast the resolved original/preview URL.
-
----
-
-## 6. Effort estimate (S/M/L)
-
-| Behavior | Effort | Notes |
-|----------|--------|-------|
-| Zoom (wheel + double-tap toggle) | **M** | Drop in `react-zoom-pan-pinch`; wire reset-on-change. |
-| Pan | **S** | Free with the zoom lib. |
-| Keyboard shortcuts (z/s/space + context Escape) | **S** | Extend existing keydown effect. |
-| Swipe/touch navigation | **S–M** | Add gesture lib; gate on scale. |
-| Progressive thumbnail→preview→original | **M–L** | L if a backend preview tier / thumbhash must be added; M if two-tier (thumbnail→original). |
-| Neighbor preloading | **S–M** | Port direction-aware preloader. |
-| Re-entrancy guard on nav | **S** | Small flag/tracker. |
-| Copy image to clipboard | **S** | Canvas → `ClipboardItem`. |
-| Fullscreen mode | **S** | Fullscreen API. |
-| Slideshow core (play/stop/interval/progress/autoplay) | **M** | State machine + timer + fullscreen. |
-| Slideshow shuffle + history (back/forward) | **M** | Port `SlideshowHistory` + `onRandom`. |
-| Slideshow repeat + ascending/descending order | **S** | Branching in advance logic. |
-| Slideshow looks (contain/cover/blurred) | **S–M** | Blurred needs a thumbhash/blur source. |
-| Slideshow settings modal + persistence | **M** | New modal + localStorage. |
-| Slideshow metadata overlay (2 modes) | **S** | Find already has caption/date/location data. |
-| Casting | **L** | Out of scope — note only. |
diff --git a/docs/overhaul/inventory/lane-e-backend-api.md b/docs/overhaul/inventory/lane-e-backend-api.md
deleted file mode 100644
index 6ccaad24..00000000
--- a/docs/overhaul/inventory/lane-e-backend-api.md
+++ /dev/null
@@ -1,208 +0,0 @@
-# Lane E — Backend / API Surface Inventory
-
-Read-only discovery. Reference = NestJS (`reference-app/server/src`). Find = FastAPI (`backend/src/find_api`). All paths under reference are prefixed `/api` in deployment; controller decorators below show the route segment.
-
----
-
-## 1. Reference route surface (by domain)
-
-### Timeline — `@Controller('timeline')`
-| Method | Path | Purpose |
-|---|---|---|
-| GET | `/timeline/buckets` | List time buckets (bucket id + asset count) for a filtered set |
-| GET | `/timeline/bucket` | All assets in one bucket as columnar arrays |
-
-### Album — `@Controller('albums')`
-| Method | Path | Purpose |
-|---|---|---|
-| GET | `/albums` | List albums (filter by `?shared`, `?assetId`) |
-| POST | `/albums` | Create album (with optional initial assetIds) |
-| GET | `/albums/statistics` | Owned/shared/notShared counts |
-| GET | `/albums/:id` | Album detail + assets |
-| PATCH | `/albums/:id` | Update name/description/order/activity/thumbnail |
-| DELETE | `/albums/:id` | Delete album |
-| GET | `/albums/:id/map-markers` | Geo markers for album assets |
-| PUT | `/albums/:id/assets` | Add assets to album |
-| PUT | `/albums/assets` | Add assets to *multiple* albums |
-| DELETE | `/albums/:id/assets` | Remove assets from album |
-| PUT | `/albums/:id/users` | Add shared users (with role) |
-| PUT | `/albums/:id/user/:userId` | Update a user's role |
-| DELETE | `/albums/:id/user/:userId` | Remove shared user |
-
-### Shared-link — `@Controller('shared-links')`
-| Method | Path | Purpose |
-|---|---|---|
-| GET | `/shared-links` | List my shared links |
-| POST | `/shared-links/login` | Authenticate against a password-protected link |
-| GET | `/shared-links/me` | Resolve current link context (by key/slug/password) |
-| GET | `/shared-links/:id` | Get one link |
-| POST | `/shared-links` | Create link (type ALBUM or INDIVIDUAL) |
-| PATCH | `/shared-links/:id` | Edit link (password/expiry/permissions/slug) |
-| DELETE | `/shared-links/:id` | Delete link |
-| PUT | `/shared-links/:id/assets` | Add assets to an individual link |
-| DELETE | `/shared-links/:id/assets` | Remove assets from a link |
-
-### Partner — `@Controller('partners')`
-| Method | Path | Purpose |
-|---|---|---|
-| GET | `/partners` | List partners (`?direction=shared-by`/`shared-with`) |
-| POST | `/partners` | Create partner share |
-| POST | `/partners/:id` | (legacy create-by-id) |
-| PUT | `/partners/:id` | Update `inTimeline` flag |
-| DELETE | `/partners/:id` | Remove partner |
-
-### Asset-state — `@Controller(Asset)` (`/assets`) + `trash` + `download`
-| Method | Path | Purpose |
-|---|---|---|
-| GET | `/assets/statistics` | Counts by favorite/archive/total |
-| PUT | `/assets` | Bulk update: `ids[]` + isFavorite / visibility / dateTimeOriginal / lat-lng / rating / description / duplicateId |
-| DELETE | `/assets` | Bulk soft-delete (trash) or force delete |
-| PATCH | `/assets` | Bulk patch |
-| GET/PUT/PATCH | `/assets/:id` | Get / replace / patch single asset (isFavorite, visibility, etc.) |
-| POST | `/trash/empty` | Permanently delete all trashed |
-| POST | `/trash/restore` | Restore all trashed |
-| POST | `/trash/restore/assets` | Restore specific ids |
-| POST | `/download/info` | Compute archive size/contents |
-| POST | `/download/archive` | Stream zip of selected assets |
-
-> `visibility` enum = `archive | timeline | hidden | locked`. Favorite = `isFavorite` flag. Trash = soft-delete via `DELETE /assets` then `/trash/*`. So **archive, favorite, and trash are all properties of the asset**, not separate collections.
-
-### User / Auth — `@Controller('auth')`, `@Controller(User)`, `@Controller('sessions')`
-| Method | Path | Purpose |
-|---|---|---|
-| POST | `/auth/login`, `/auth/logout`, `/auth/admin-sign-up`, `/auth/validateToken`, `/auth/change-password` | Auth lifecycle |
-| GET/POST/PUT/DELETE | `/auth/pin-code`, `/auth/session/lock`, `/auth/session/unlock` | PIN + locked-folder session |
-| GET | `/users`, `/users/me`, `/users/:id` | User listing/self |
-| GET/PUT | `/users/me/preferences` | User preferences (incl. UI/timeline prefs) |
-| GET | `/users/me/calendar-heatmap` | Activity heatmap for timeline scrubber |
-| various | `/users/me/license`, `/users/me/onboarding`, `/users/profile-image` | Profile bits |
-| POST/GET/DELETE/PUT/PATCH | `/sessions...` | Device session management |
-
-### Adjacent (relevant context, not in scope to port)
-- `@Controller('memories')`, `@Controller('stacks')`, `@Controller('activities')`, `@Controller('view')` (folder browsing). Activities back album comments/likes.
-
----
-
-## 2. Timeline contract (PRIORITY — Phase 3 consumer)
-
-Source: `reference-app/server/src/dtos/time-bucket.dto.ts`, `controllers/timeline.controller.ts`.
-
-### GET `/timeline/buckets` — bucket count list
-Query (`TimeBucketDto`, all optional unless noted):
-```
-userId, albumId, personId, tagId (uuid)
-isFavorite, isTrashed (bool)
-withStacked, withPartners, withCoordinates (bool)
-order = ASC | DESC (within-bucket sort)
-orderBy = takenAt | createdAt (grouping date)
-visibility = archive | timeline | hidden | locked
-bbox = "west,south,east,north" (WGS84)
-key, slug (shared-link context)
-```
-Response: `TimeBucketsResponseDto[]`
-```jsonc
-[ { "timeBucket": "2024-01-01", // YYYY-MM-DD, start of period (month granularity)
- "count": 42 } ]
-```
-
-### GET `/timeline/bucket` — assets in one bucket (columnar)
-Query = same base as above **plus required** `timeBucket: "YYYY-MM-DD"`.
-Response `TimeBucketAssetResponseDto` — **parallel arrays, all same length, indexed positionally** (not a list of objects; chosen for payload size):
-```jsonc
-{
- "id": ["..."], // asset ids
- "ownerId": ["..."],
- "ratio": [1.5], // width/height aspect ratio
- "isFavorite": [true],
- "visibility": ["timeline"],
- "isTrashed": [false],
- "isImage": [true], // false => video
- "thumbhash": ["...", null],// base64 blurhash for placeholder
- "createdAt": ["ISO ts"], // upload time (UTC)
- "fileCreatedAt": ["ISO ts"], // capture time (UTC)
- "localOffsetHours": [5.5], // signed, fractional; apply to fileCreatedAt for local time
- "duration": [12000, null],// ms; null for stills
- "stack": [["stackId", 3], null], // optional [stackId, count] tuple
- "projectionType":[null], // 360 content
- "livePhotoVideoId":[null],
- "city": [null], "country": [null], // optional, only when withCoordinates
- "latitude":[null], "longitude":[null] // optional, only when withCoordinates
-}
-```
-Note: response sent with explicit `Content-Type: application/json` header (large preserialized payload). Both endpoints allow shared-link auth.
-
----
-
-## 3. Find route surface (today)
-
-| Router | Routes |
-|---|---|
-| auth | `POST /auth/setup`, `POST /auth/login`, `POST /auth/logout`, `GET /auth/me`, `POST+GET /auth/invites`, `POST /auth/join`, `GET /auth/join-requests`, `POST /auth/join-requests/{id}/approve`, `.../reject` |
-| gallery | `GET /gallery/counts`, `GET /gallery`, `GET /image/{id}`, `GET /image/{id}/thumbnail`, `POST /thumbnails/backfill`, `POST /image/{id}/like`, `POST /image/{id}/reprocess`, `DELETE /image/{id}`, `POST /images/bulk-delete` |
-| search | `GET /search` |
-| people | `GET /people`, `GET /people/{id}/images`, `PATCH /people/{id}`, `POST /people/cluster` |
-| clusters | `GET /clusters`, `GET /cluster/{id}`, `PATCH /cluster/{id}`, `POST /cluster/run`, `POST /cluster/trigger` |
-| duplicates | `GET /api/duplicates`, `POST /api/image/{id}/keep` |
-| feedback | search/caption/object rating + correction endpoints, `GET /feedback/stats`, `GET /people/feedback` |
-| vault | `POST /vault/unlock`, `GET /vault/list`, `POST /vault/lock`, `POST /vault/hide`, `GET /vault/stream/{id}` |
-| upload | `POST /upload`, `POST /upload/bulk` |
-| status | `GET /status/models`, `GET /status/{job_id}` |
-| config | `GET /config` |
-
-`GET /gallery` supports rich filtering already: `skip/limit`, `status` (processing-state only), `liked`, `sort_order`, date-range presets + custom `date_start/date_end` + `date_from/date_to`, camera make/model, min width/height, `file_type`, `orientation`. `GET /gallery/counts` returns only processing-state buckets (`all/indexed/processing/failed`).
-
----
-
-## 4. Gap table
-
-| Domain | Status in Find | Notes |
-|---|---|---|
-| **Timeline buckets** | **Missing** | No bucket-count or bucket-window endpoint. `GET /gallery` is offset-paginated, not month-bucketed. This is the #1 Phase 3 build. |
-| **Albums** | **Missing** | No album model, router, or asset-album join. Entire domain absent. |
-| **Shared links** | **Missing** | No public/anonymous link sharing. Find sharing is multi-user within one instance only (invites/join). |
-| **Partner sharing** | **Missing** | No partner concept. |
-| **Favorites** | **Partial** | `liked` column + `POST /image/{id}/like`; no bulk toggle, no `isFavorite` filter parity with timeline params. |
-| **Archive** | **Missing** | No archive visibility state. Only `is_hidden`/`vault_state` (vault) exists, which is a different concept. |
-| **Trash** | **Partial / hard-delete** | `DELETE /image/{id}` and `/images/bulk-delete` are hard deletes. No soft-delete/`deleted_at`, no restore, no trash list/empty. |
-| **Asset bulk state update** | **Missing** | No `PUT /assets` bulk endpoint (favorite/visibility/date/rating/description). |
-| **Calendar heatmap** | **Missing** | Needed if timeline scrubber wants density data. |
-| **Auth/users** | **Partial** | Find has its own invite/join model (different from reference). User prefs endpoint absent. |
-| **Download archive (zip)** | **Missing** | No multi-asset zip download. |
-
----
-
-## 5. Schema diff (tables/columns Find must add)
-
-Consolidated; other lanes own field-level detail.
-
-**Media (existing table) — add columns:**
-- `is_archived` (bool) **or** a `visibility` enum (`timeline|archive|hidden|locked`) — reconcile with existing `is_hidden`/`vault_state`.
-- `deleted_at` (timestamptz, nullable) for soft-delete/trash + index; retention for auto-empty.
-- `taken_at` / `file_created_at` (timestamptz) — capture time distinct from `created_at` (upload). Timeline buckets group by capture date; currently only `created_at` exists. Likely derivable from `exif_json` but should be a first-class indexed column for bucket queries.
-- `local_offset_hours` (float, nullable) for `localOffsetHours` in the bucket response.
-- `thumbhash`/blurhash (string, nullable) for placeholder rendering (timeline contract field).
-
-**New tables:**
-- `albums` (id, owner_user_id, name, description, order, is_activity_enabled, album_thumbnail_media_id, created_at, updated_at).
-- `album_media` (album_id, media_id, added_at) — many-to-many.
-- `album_users` (album_id, user_id, role: `editor|viewer`) — album sharing.
-- `shared_links` (id, owner_user_id, type `album|individual`, album_id?, key/slug, password_hash?, expires_at, allow_upload, allow_download, show_metadata, created_at).
-- `shared_link_media` (shared_link_id, media_id) — for individual-asset links.
-- `partners` (shared_by_id, shared_with_id, in_timeline) — optional, lower priority.
-
-**Users:** reference has rich preferences; Find's `users` table is minimal. A `user_preferences` (JSON) column or table may be needed for timeline/UI prefs.
-
-Migrations: Find has 4 alembic versions today (vault schema, vault state metadata, duplicate_of, hnsw index). New work needs additive migrations on `media` plus the new tables above.
-
----
-
-## 6. Sequencing note (backend-before-frontend)
-
-1. **Media schema additions first** — `taken_at`, `deleted_at`, `is_archived`/`visibility`, `local_offset_hours`, `thumbhash`. Everything else depends on these columns.
-2. **Timeline bucket endpoints** (`/timeline/buckets`, `/timeline/bucket`) — highest priority; Phase 3 frontend timeline cannot start until the columnar contract exists. Depends on `taken_at` + state columns being filterable.
-3. **Asset-state mutations** — bulk `PUT /assets` (favorite/archive/visibility) + soft-delete/trash (`deleted_at`, restore, empty). Timeline filters (`isFavorite`, `isTrashed`, `visibility`) and the archive/favorites/trash views all consume these.
-4. **Albums** — model + CRUD + asset add/remove, then album sharing (album_users). Album view and album-scoped timeline (`albumId` filter) depend on this.
-5. **Shared links** — depends on albums (for ALBUM type) and assets (INDIVIDUAL type). Public-link auth path is independent of internal user auth — flag: anonymous/password access surface, design carefully.
-6. **Partners** — lowest priority; independent, can land anytime after asset-state.
-
-Frontend can build the timeline shell against a mocked bucket contract, but real data needs steps 1-2. Archive/favorites/trash views need step 3. Albums/sharing UI need steps 4-5.
diff --git a/docs/overhaul/inventory/lane-f-ml.md b/docs/overhaul/inventory/lane-f-ml.md
deleted file mode 100644
index f4aefc0b..00000000
--- a/docs/overhaul/inventory/lane-f-ml.md
+++ /dev/null
@@ -1,108 +0,0 @@
-# Lane F — ML Models Inventory (Reference vs Find)
-
-Read-only discovery. Reference ML = Immich machine-learning service (FastAPI + ONNX Runtime).
-Find ML = `backend/src/find_api/ml/` (PyTorch / Ultralytics / InsightFace / Transformers / PaddleOCR, driven by RQ workers + a `ModelManager`).
-
-Cited paths are relative to repo root `C:\Users\abhas\Desktop\Find`.
-
----
-
-## 1. Reference ML models (Immich)
-
-Reference is built entirely around **ONNX Runtime** (`onnxruntime as ort`) with a pluggable execution-provider stack and downloadable model variants (ONNX / ARMNN / RKNN). It does not run PyTorch at inference time.
-
-### Embedding / search (CLIP)
-- **Architecture:** OpenCLIP-family visual + textual encoders, run as ONNX sessions.
- `OpenClipVisualEncoder(BaseCLIPVisualEncoder)` — `reference-app/machine-learning/immich_ml/models/clip/visual.py:61`; runs `self.session.run(...)` (`visual.py:31`). Textual twin in `models/clip/textual.py`.
-- **Supported model catalog:** large `_OPENCLIP_MODELS` set incl. RN50/RN101, `ViT-B-16__openai`, `ViT-B-32__openai`, `ViT-L-14*`, and many **SigLIP / SigLIP2** webli variants (e.g. `ViT-B-16-SigLIP__webli`, `ViT-SO400M-14-SigLIP2-378__webli`) — `models/constants.py:4-59`. Multilingual M-CLIP (`_MCLIP_MODELS`, `constants.py:62-67`) and NLLB-CLIP for i18n.
-- **Source routing:** `get_model_source()` maps a model name to `OPENCLIP` / `MCLIP` / `INSIGHTFACE` / `PADDLE` — `constants.py:163-178`.
-- **Preprocessing** is config-driven from `preprocess_cfg.json` (size/mean/std/interpolation) — `visual.py:46-77`, so swapping CLIP variants needs no code change.
-
-### Facial recognition
-- **Detection:** InsightFace **RetinaFace** wrapped over an ONNX session — `models/facial_recognition/detection.py:4,20-25`. Default `min_score=0.7`, `input_size=(640,640)` (`detection.py:16,23`). Output = boxes/scores/landmarks (`detection.py:31-35`).
-- **Recognition:** InsightFace **ArcFaceONNX** — `models/facial_recognition/recognition.py:7,40-43`. Emits 512-d face embeddings; supports dynamic batch axis injection into the ONNX graph (`recognition.py:79-87`) and batched inference (`recognition.py:56-64`).
-- **Model packs:** `_INSIGHTFACE_MODELS = {antelopev2, buffalo_s, buffalo_m, buffalo_l}` — `constants.py:70-75`. (`buffalo_*` = RetinaFace det + ArcFace recog bundles; `_s` is the CPU-light variant.)
-
-### OCR
-- PaddleOCR **PP-OCRv5** server/mobile variants (`_PADDLE_MODELS`, `constants.py:78-89`), also run via ONNX (`models/ocr/detection.py`, `recognition.py`).
-
-### ONNX execution providers (the key asset)
-- `SUPPORTED_PROVIDERS` preference order — `constants.py:91-97`:
- `CUDAExecutionProvider → MIGraphXExecutionProvider (ROCm) → OpenVINOExecutionProvider → CoreMLExecutionProvider → CPUExecutionProvider`.
-- `OrtSession` auto-selects the **intersection of supported and actually-available** providers, descending preference — `sessions/ort.py:109-113`. This is a clean CUDA→CPU fallback with no code change.
-- Per-provider tuned options — `sessions/ort.py:124-168` (CPU arena strategy, CUDA device id, OpenVINO GPU/CPU autodetect + precision, CoreML, MIGraphX fp16 cache).
-- **CPU thread tuning:** when running CPU-only it sets `inter_op=1`, `intra_op=2` and arena defaults that "work well for CPU" — `sessions/ort.py:181-203`.
-
-### CPU / quantized / accelerated variants
-- **ARMNN** (ARM CPU/GPU) and **RKNN** (Rockchip NPU) model formats selected automatically — `models/base.py:161-176` (`_model_format_default`: RKNN if available, else ARMNN, else ONNX). Format-specific files downloaded via `snapshot_download` with ignore patterns — `base.py:68-79`.
-- Dedicated sessions exist: `sessions/ann/loader.py` (ARMNN) and `sessions/rknn/rknnpool.py`.
-- OpenVINO precision + ROCm precision (FP32/FP16) are settings — `config.py:75-76`; ANN fp16 turbo flag — `config.py:69`.
-
----
-
-## 2. Find ML models (today)
-
-Find runs PyTorch/library models in-process, leased through a singleton `ModelManager`. Three modes: `ML_MODE ∈ {full, mock, remote}` — `backend/src/find_api/core/config.py:43`.
-
-| Capability | Library / model | File |
-|---|---|---|
-| Embedding | **open_clip SigLIP `ViT-B-16-SigLIP` / `webli`**, 768-d | `ml/clip_embedder.py:33-37`; defaults `config.py:50-51,64` |
-| Object detection | **Ultralytics YOLO `yolo26n.pt`** (also `yolov10b.pt` at backend root) | `ml/object_detector.py:29`; default `config.py:53` |
-| Face detect + recognize | **InsightFace `antelopev2`** via `FaceAnalysis` (det+recog+age/gender in one), 512-d embedding | `ml/face_detector.py:35-40,67` |
-| Captioning | **Florence-2-base** (`microsoft/Florence-2-base`, Transformers) | `ml/captioner.py`; default `config.py:52` |
-| OCR | **PaddleOCR** (PP-OCR, lang=en, CPU) | `ml/ocr.py:11,20` |
-| Clustering / ranking | local (`ml/clusterer.py`, `ml/search_ranking.py`) | — |
-| Mock | deterministic fake embedder for tests | `ml/mock_embedder.py` |
-
-### ML interface
-- **ModelManager** (`core/model_manager.py`): singleton, lazy `get_model(name, loader, config_key)` + `use_model()` context lease so idle cleanup can't unload mid-inference (`model_manager.py:142-242`). LRU eviction at `ML_MAX_LOADED_MODELS=5` (`config.py:49`, `model_manager.py:340-363`), idle TTL unload `ML_MODEL_IDLE_TTL_SECONDS=300` (`config.py:48`, `model_manager.py:256-291`), `config_key` fingerprint forces reload on config change and gates failure-retry (`model_manager.py:163-177`), per-process status published to Redis (`model_manager.py:324-333`).
-- **GPU handling:** simple boolean `USE_GPU` (`config.py:54`); each model picks `cuda` vs `cpu` itself (`clip_embedder.py:31`, `object_detector.py:30,55`, `face_detector.py:27-29`). No execution-provider abstraction, no automatic CUDA→CPU fallback inside ORT (relies on torch / library defaults).
-- **Remote ML:** `ML_MODE=remote` offloads to a self-hosted Find ML HTTP server; requires `REMOTE_ML_URL` + `REMOTE_ML_API_KEY` (validated, `config.py:104-118`; `test_remote_ml_config.py`). Feature flags `REMOTE_ML_FEATURES="embed,caption,detect,ocr,cluster"` (`config.py:47`).
-- **Hybrid embedding:** `generate_hybrid_embedding()` in `workers/processors.py` blends image + text(caption/label) CLIP vectors; covered by `tests/test_hybrid_embedding.py` (math-only, mocked).
-
----
-
-## 3. Adopt / Keep / Defer
-
-| Capability | Find today | Reference | Recommendation | Reasoning (CPU latency + license) |
-|---|---|---|---|---|
-| **ONNX Runtime + EP fallback layer** | none (torch + lib defaults) | `OrtSession` w/ auto provider intersection + CPU thread tuning (`ort.py:109-203`) | **ADOPT (highest value)** | Biggest CPU-mode win. ORT-quantized/ONNX CLIP+ArcFace are far faster and lighter on CPU than torch+open_clip+FaceAnalysis. Gives clean CUDA→CPU→CoreML fallback for free. Apache-2.0 (ORT). |
-| **CLIP embedding** | open_clip SigLIP ViT-B-16 (torch, 768-d) | OpenCLIP **ONNX** incl. SigLIP/SigLIP2 + smaller ViT-B-32 (`constants.py:13-58`) | **ADOPT model-as-ONNX; KEEP SigLIP family** | Keep the SigLIP choice (good quality), but run it as ONNX. Optionally offer `ViT-B-32__openai` ONNX as a low-latency CPU default. Models: OpenAI CLIP = MIT; SigLIP/webli weights = Apache-2.0; check per-checkpoint. Re-embeds if dim/model changes → migration cost. |
-| **Face detect + recognize** | InsightFace `antelopev2` (`FaceAnalysis`, 512-d) | InsightFace RetinaFace + ArcFaceONNX, packs incl. **`buffalo_s`** (`constants.py:70-75`) | **ADOPT `buffalo_s` (ONNX) for CPU; KEEP antelopev2 for GPU/quality** | `buffalo_s` is markedly faster on CPU than antelopev2 with small accuracy loss; both 512-d ArcFace so embeddings stay compatible-ish (still a re-index). LICENSE FLAG: InsightFace models (antelopev2, buffalo_*) are **non-commercial / research-only** — applies to both apps. |
-| **Object detection** | Ultralytics YOLO `yolo26n.pt` | not present (Immich has no object detector) | **KEEP** | Reference offers nothing here. LICENSE FLAG: Ultralytics YOLO is **AGPL-3.0** — matches Find's new AGPL license, so OK for this repo. |
-| **OCR** | PaddleOCR PP-OCR (en) | PaddleOCR PP-OCRv5 server/mobile as ONNX (`constants.py:78-89`) | **DEFER → later ADOPT mobile-ONNX** | Same engine family; reference's `PP-OCRv5_mobile` ONNX is the CPU-friendly variant. Low priority. PaddleOCR = Apache-2.0. |
-| **Captioning** | Florence-2-base (Transformers) | not present | **KEEP** | Reference has no captioner. Florence-2 = MIT. Heavy on CPU; consider lazy/optional. |
-| **Model lifecycle (manager)** | Find `ModelManager` (lease, LRU, TTL, Redis status, remote mode) | Immich `ModelCache` (TTL via `model_ttl`) | **KEEP Find's** | Find's manager is richer (in-flight leasing, failure gating, remote mode). No reason to adopt reference's. |
-
----
-
-## 4. CPU-mode considerations
-
-- **Find today is torch-first** → on CPU it loads full PyTorch CLIP + InsightFace + (optionally) Florence-2, which is memory-heavy and slow. `USE_GPU=False` just flips device strings; there is no ONNX/quantized path.
-- **Reference is ONNX-first** and already solves CPU well:
- - Automatic provider selection = intersection of `SUPPORTED_PROVIDERS` and available (`ort.py:109-113`) → **CUDA→CPU fallback is automatic**, never crashes when CUDA absent.
- - CPU-specific thread tuning (`inter_op=1`, `intra_op=2`, arena strategy) — `ort.py:181-203`.
- - CPU-light model variants ready: **`buffalo_s`** (face), **`ViT-B-32__openai`** (CLIP), **`PP-OCRv5_mobile`** (OCR).
- - Extra CPU/NPU formats (ARMNN, RKNN) auto-selected for ARM/Rockchip hardware — `base.py:161-176`.
-- **Recommended CPU default stack for Find:** ORT CPU EP + `ViT-B-32` (or SigLIP-B-16) CLIP ONNX + `buffalo_s` face ONNX. This is the single biggest latency/memory win and the top adopt candidate.
-
-## 5. License notes (flags)
-
-- **InsightFace models** (`antelopev2`, `buffalo_*`): **non-commercial / research-only** — already a risk in Find today (it ships `antelopev2`), not introduced by adoption. Worth flagging for an AGPL open-source app; an Apache/MIT face model may be preferable long-term.
-- **Ultralytics YOLO** (`yolo26n.pt`, `yolov10b.pt`): **AGPL-3.0** — consistent with Find's relicense to AGPL-3.0 (commit 639b6ec). OK in-repo; downstream redistributors inherit AGPL.
-- **CLIP weights:** OpenAI CLIP = MIT; OpenCLIP/SigLIP webli = Apache-2.0 (verify per checkpoint). Low risk.
-- **PaddleOCR / PP-OCR:** Apache-2.0. Low risk.
-- **Florence-2:** MIT. Low risk.
-- **ONNX Runtime:** MIT/Apache-2.0. Low risk.
-
-## 6. Effort estimates
-
-| Adoption | Size | Notes |
-|---|---|---|
-| ORT session wrapper + EP fallback (port `OrtSession` concept, not verbatim) into Find's ModelManager | **M** | New session abstraction + provider selection; integrate with existing `use_model`/`config_key`. |
-| CLIP → ONNX (SigLIP B-16 and/or ViT-B-32 CPU default) | **M** | Export/obtain ONNX + preprocess_cfg; re-embed library (migration). Embedding dim change = full re-index. |
-| Face → `buffalo_s` ONNX (CPU) with antelopev2 GPU fallback | **M** | Both ArcFace 512-d; swap detector/recognizer to ONNX; re-index face vectors. |
-| OCR → PP-OCRv5_mobile ONNX | **S–M** | Same engine; lower priority (Defer). |
-| Keep YOLO / Florence-2 / ModelManager | **S** (no-op) | No change. |
-
-**Cross-cutting risk:** any embedding model swap forces a re-embed/re-index of existing photos and faces; sequence behind a migration plan.
diff --git a/docs/overhaul/inventory/lane-g-settings.md b/docs/overhaul/inventory/lane-g-settings.md
deleted file mode 100644
index 1de790ba..00000000
--- a/docs/overhaul/inventory/lane-g-settings.md
+++ /dev/null
@@ -1,222 +0,0 @@
-# Lane G — Settings Panel Inventory & Spec
-
-Read-only discovery of the reference app's settings architecture, compared against Find's
-current config surface, to drive Find's Phase 5 settings spec.
-
-Reference settings split into two tiers:
-- **User preferences** (per-user, self-service) — `web/src/routes/(user)/user-settings/`
- backed by `server/src/dtos/user-preferences.dto.ts`.
-- **System config** (admin-only, instance-wide) — `web/src/routes/admin/system-settings/`
- backed by `server/src/dtos/system-config.dto.ts` (`SystemConfigSchema`).
-
----
-
-## 1. Reference Settings Taxonomy
-
-### 1A. User preferences (per-user)
-
-Panels: `web/src/routes/(user)/user-settings/UserSettingsList.svelte:50-107` mounts each
-section in a `SettingAccordion`. Field schema: `server/src/dtos/user-preferences.dto.ts`.
-
-| Group | Field | Control | Purpose | Cite |
-|---|---|---|---|---|
-| **App / Appearance** | theme: system vs light/dark | Switch + theme manager | Color theme preference | `AppSettings.svelte:67-71` |
-| | use browser locale | Switch | Auto-detect language | `AppSettings.svelte:76-77` |
-| | custom locale | Combobox (locale list) | Manual language pick | `AppSettings.svelte:81-87` |
-| | display original photos | Switch | Show originals vs thumbnails | `AppSettings.svelte:92` |
-| | video hover autoplay | Switch | Hover-to-play | `AppSettings.svelte:96` |
-| | video viewer autoplay / loop / play original | Switch ×3 | Video playback prefs | `AppSettings.svelte:101-111` |
-| | permanent deletion warning | Switch | Confirm-on-delete | `AppSettings.svelte:115` |
-| **Account** | profile (name, email, avatar color) | Text + color picker | Identity | `UserProfileSettings.svelte`; avatar color `user-preferences.dto.ts:14-19` |
-| | change password | Form | Credential rotation | `ChangePasswordSettings.svelte` |
-| | PIN code (create/change) | Form | Vault/secure-folder PIN | `PinCodeSettings.svelte` |
-| **API keys** | key list (create/revoke) | Grid/list | Programmatic access tokens | `UserApiKeyList.svelte` |
-| **Authorized devices** | session/device list | List + revoke | Active session management | `DeviceList.svelte`, `DeviceCard.svelte` |
-| **Download** | archive size (bytes) | Number | Max zip size | `user-preferences.dto.ts:78-84` |
-| | include embedded videos | Switch | Bundle motion-photo video | same |
-| **Features** (per-user toggles) | folders (enabled, sidebarWeb) | Switch ×2 | Folder view | `user-preferences.dto.ts:36-42` |
-| | people (enabled, sidebarWeb, minimumFaces) | Switch ×2 + Number | Face/people UI | `user-preferences.dto.ts:44-51` |
-| | tags (enabled, sidebarWeb) | Switch ×2 | Tag UI | `user-preferences.dto.ts:61-67` |
-| | sharedLinks (enabled, sidebarWeb) | Switch ×2 | Shared-link UI | `user-preferences.dto.ts:53-59` |
-| | memories (enabled, duration) | Switch + Number | Memories feature | `user-preferences.dto.ts:21-27` |
-| | ratings (enabled) | Switch | Star ratings | `user-preferences.dto.ts:29-34` |
-| | cast (gCastEnabled) | Switch | Google Cast | `user-preferences.dto.ts:94-99` |
-| | albums (defaultAssetOrder) | Select (asc/desc) | Default sort | `user-preferences.dto.ts:6-12` |
-| **Notifications** | email notifications (enabled, albumInvite, albumUpdate) | Switch ×3 | Email opt-in | `user-preferences.dto.ts:69-76` |
-| **OAuth** | unlink OAuth account | Button | Per-user identity link | `OauthSettings.svelte` |
-| **Partner sharing** | partner list | List | Share whole library w/ partner | `PartnerSettings.svelte` |
-| **Purchase** | support badge, hide-buy-until | Switch + date | Donation nag (skip for Find) | `user-preferences.dto.ts:86-92` |
-
-### 1B. System config (admin, instance-wide)
-
-Root: `SystemConfigSchema` `system-config.dto.ts:401-427`. Panels in
-`web/src/routes/admin/system-settings/*.svelte` (one file per group).
-
-| Group | Key fields | Control types | Cite |
-|---|---|---|---|
-| **Machine Learning** | enabled; urls[]; availabilityChecks(enabled/timeout/interval); clip; duplicateDetection; facialRecognition; ocr | Switch, URL-list, nested model-config | `system-config.dto.ts:173-183`; per-model `model-config.dto.ts` (modelName, enabled, minScore, maxDistance) |
-| **Image / Thumbnails** | thumbnail(format/quality/size/progressive); preview(same); fullsize(enabled/format/quality); colorspace; extractEmbedded | Select, Number(1-100 quality), Switch | `system-config.dto.ts:360-386` |
-| **FFmpeg / Transcode** | crf, threads, preset, target/accepted video+audio codecs, containers, resolution, maxBitrate, bframes, refs, gopSize, temporalAQ, cqMode, twoPass, **preferredHwDevice**, transcode policy, **accel (HW accel)**, accelDecode, tonemap, realtime.enabled | Number, Select, multi-Select, Switch, **HW-accel Select** | `system-config.dto.ts:92-121`; accel enum `enum.ts:502-513` |
-| **Jobs / Concurrency** | per-job-type concurrency: thumbnailGeneration, metadataExtraction, videoConversion, faceDetection, smartSearch, ocr, search, library, sidecar, migration, backgroundTask, notifications, workflow, editor, integrityCheck | Number (min 1) each | `system-config.dto.ts:38-42,123-141` |
-| **Storage Template** | enabled; hashVerificationEnabled; template string (year/month/day/preset tokens) | Switch + template editor | `system-config.dto.ts:335-354`; UI `admin-settings/StorageTemplateSettings.svelte` |
-| **Library** | scan(enabled, cronExpression); watch(enabled) | Switch + cron | `system-config.dto.ts:143-156` |
-| **Server** | externalDomain, loginPageMessage, publicUsers | Text/URL, Switch | `system-config.dto.ts:287-298` |
-| **OAuth (system)** | enabled, issuerUrl, clientId/Secret, scope, prompt, autoLaunch, autoRegister, buttonText, signing algos, claims, mobile override/redirect, timeout, allowInsecure, defaultStorageQuota | Switch, Text, Number | `system-config.dto.ts:220-272` |
-| **Password login** | enabled | Switch | `system-config.dto.ts:274-276` |
-| **Notifications (SMTP)** | smtp(enabled, from, replyTo, transport{host,port,secure,username,password,ignoreCert}) | Switch, Text, Number | `system-config.dto.ts:300-322` |
-| **Templates (email)** | albumInvite/welcome/albumUpdate templates | Textarea | `system-config.dto.ts:324-333` |
-| **Trash** | enabled, days | Switch + Number | `system-config.dto.ts:388-393` |
-| **Backup (DB)** | database(enabled, cronExpression, keepLastAmount) | Switch, cron, Number | `system-config.dto.ts:58-90` |
-| **Nightly tasks** | startTime; databaseCleanup, missingThumbnails, clusterNewFaces, generateMemories, syncQuotaUsage | Time + Switch ×5 | `system-config.dto.ts:204-218` |
-| **Integrity checks** | missingFiles/untrackedFiles (enabled+cron); checksumFiles (+timeLimit, percentageLimit) | Switch, cron, Number | `system-config.dto.ts:66-88` |
-| **Logging** | enabled, level | Switch + Select | `system-config.dto.ts:158-163` |
-| **Map** | enabled, lightStyle URL, darkStyle URL | Switch + URL | `system-config.dto.ts:185-191` |
-| **Reverse geocoding** | enabled | Switch | `system-config.dto.ts:278-280` |
-| **Metadata** | faces.import | Switch | `system-config.dto.ts:282-285` |
-| **New version check** | enabled, channel (stable/RC) | Switch + Select | `system-config.dto.ts:200-202` |
-| **Theme (system)** | customCss | Textarea | `system-config.dto.ts:356-358` |
-| **User (system)** | deleteDelay | Number | `system-config.dto.ts:395-399` |
-
----
-
-## 2. Find Settings Spec (proposed Phase 5)
-
-Find is a small-team / self-host photo app. Collapse the reference's two-tier
-(user-prefs + 20-group admin) model into **6 pragmatic groups**. Single settings page
-with sections; admin-only sections gated by role.
-
-### General
-- Appearance handled separately below. Server identity: external URL / login message — **skip for now** (YAGNI until multi-instance).
-- New-version check (enabled) — **keep** (open-source, useful). Channel — skip.
-
-### Library / Storage
-- Storage backend display (minio/local) — read-only status (already in `core/config.py:77`).
-- Trash: enabled + retention days — **keep** (map `SystemConfigTrashSchema`).
-- Storage template — **YAGNI-skip** Phase 5 (Find uses object-store keys, not path templates).
-- Library scan/watch cron — **skip** unless Find adds external-library import.
-- Upload limits (max size, bulk count) — surface read-only from `core/config.py:58-61`.
-
-### Machine Learning
-- ML enabled toggle.
-- **ML mode**: full / mock / remote (Find already has `ML_MODE` `core/config.py:43`).
-- Remote ML: URL + API key + features + strip-EXIF (Find already has these `core/config.py:44-47`).
-- Per-task toggles: captioning, detection, OCR, face clustering, embedding — map to Find's
- `REMOTE_ML_FEATURES` and `ml/*` modules. Confidence thresholds (minScore/maxDistance) —
- optional advanced.
-- Model names (CLIP/BLIP/YOLO) — advanced/read-only (`core/config.py:50-53`).
-- **Hardware acceleration toggle lives here** (see §3).
-
-### Sharing
-- Shared links: enabled, default expiry — **keep** (Find has sharing per Lane B).
-- Partner sharing — **YAGNI-skip** Phase 5.
-- Public users / OAuth — **skip** Phase 5 (Find auth is small-team session-based,
- `core/config.py:72-74`); revisit if OAuth added.
-
-### Appearance (per-user)
-- Theme: system / light / dark (map `AppSettings.svelte:67`).
-- Language / locale (map `AppSettings.svelte:76-87`).
-- Display original vs thumbnail, video autoplay/loop/hover (map `AppSettings.svelte:92-111`).
-- Default gallery sort order (map `albums.defaultAssetOrder`) — fits Find's existing
- gallery sort (recent commit #249).
-- Permanent-deletion confirm toggle.
-
-### Advanced
-- Job concurrency — single global or a few sliders (Find has `CLUSTERING_N_JOBS`
- `core/config.py:69`, `BATCH_SIZE`, `WORKER_TIMEOUT`). Map a subset, not all 15 reference job types.
-- Model lifecycle: idle TTL, max loaded models (Find has `ML_MODEL_IDLE_TTL_SECONDS`,
- `ML_MAX_LOADED_MODELS` `core/config.py:48-49`).
-- Custom CSS, logging level — optional.
-
-**Skip wholesale for Phase 5**: FFmpeg/transcode (Find is photo-first — no video transcode
-pipeline yet), SMTP/email templates, DB backup cron, integrity checks, nightly tasks, map,
-reverse geocoding, purchase/donation, Google Cast, API-key grid (unless Find ships API keys),
-storage template, OAuth system config.
-
----
-
-## 3. Hardware-Acceleration Placement (Auto / GPU / CPU)
-
-**Toggle lives in: Machine Learning settings group**, as a top-level "Compute device"
-control — NOT under transcode. Find is photo/ML-first; the reference puts HW accel under
-FFmpeg (`system-config.dto.ts:112` `accel`) because its accel is for *video transcode*.
-Find's accel is for *ML inference* (CLIP/YOLO/Florence), so it belongs with ML.
-
-- **Control**: 3-option Select/segmented — `Auto` | `GPU` | `CPU`.
- - `Auto` = detect CUDA/MPS, fall back to CPU (recommended default).
- - `GPU` = force CUDA/accelerator (error if unavailable).
- - `CPU` = force CPU.
-- **Maps to Find's existing `USE_GPU` bool** (`core/config.py:54`) — upgrade from bool to
- tri-state enum (`Auto/GPU/CPU`). Also gates `YOLO_HALF` (`core/config.py:55`,
- GPU-only fp16) and informs `CLUSTERING_BACKEND` (`core/config.py:70`).
-- **Backend capability report needed** — a new endpoint (extend `routers/config.py`, which
- today returns only `ml_mode`) returning:
- ```
- { "accel": {
- "available_devices": ["cpu", "cuda:0"], # detected
- "cuda_available": bool, "mps_available": bool,
- "device_names": ["NVIDIA RTX ...", ...],
- "current_mode": "auto|gpu|cpu",
- "effective_device": "cuda:0|cpu", # what Auto resolved to
- "fp16_supported": bool
- }}
- ```
- Detection source: `torch.cuda.is_available()` / `torch.backends.mps.is_available()`,
- used by `core/model_manager.py` (already references device/cuda). The UI uses this to
- disable "GPU" when no accelerator is present and to show the resolved device under "Auto".
-
----
-
-## 4. Find Today + GAP
-
-**`backend/src/find_api/routers/config.py` (entire file, 21 lines)** exposes a single
-read-only endpoint `GET /config` returning only `{ "ml_mode": settings.ML_MODE }`.
-No write/update endpoint, no user preferences, no system config surface.
-
-**Existing accel / ML config** in `backend/src/find_api/core/config.py` (env/Pydantic, not
-user-editable at runtime):
-- `ML_MODE` full/mock/remote (43); `REMOTE_ML_URL`/`_API_KEY`/`_STRIP_EXIF`/`_FEATURES` (44-47);
- remote-ml validated by `validate_remote_ml_config` model_validator (101-121); tested in
- `test_remote_ml_config.py`.
-- `USE_GPU: bool` (54), `YOLO_HALF` (55) — the only accel knobs today, env-only, boolean.
-- Model lifecycle `ML_MODEL_IDLE_TTL_SECONDS`, `ML_MAX_LOADED_MODELS` (48-49); model names (50-53).
-- Clustering `CLUSTERING_N_JOBS`, `CLUSTERING_BACKEND` (69-70); `BATCH_SIZE`, `WORKER_TIMEOUT` (62-63).
-- Storage `STORAGE_BACKEND` + aliases (76-86); trash has **no** config (gap vs reference).
-
-**GAP**: No settings UI anywhere. `frontend/src/app/` has clusters, duplicates, gallery,
-people, search, upload, vault — **no `settings/` route** (confirmed). All config is
-env-var / Pydantic only; nothing user-editable at runtime; no per-user preferences concept;
-`/config` is read-only and exposes one field.
-
----
-
-## 5. Persistence Note
-
-Two tiers, like the reference:
-
-- **System config** (ML mode, accel device, trash days, job concurrency, model lifecycle):
- persist in DB (new `app_config` / `system_config` table, single-row or key-value JSON),
- layered over env defaults from `core/config.py`. On startup, env provides defaults; DB
- overrides where set. Expose via new authenticated `GET/PUT /config/system` (extend
- `routers/config.py`). Reference does exactly this — `system-config.service.ts` persists the
- `SystemConfigSchema` and merges with env/file defaults.
-- **User preferences** (theme, locale, gallery sort, video autoplay, deletion confirm):
- per-user row/JSON column on the user table; `GET/PUT /config/preferences`. For appearance
- prefs that are purely client-side (theme, locale), localStorage is acceptable Phase 5,
- with DB sync as a later enhancement.
-
-Keep the read-only public `GET /config` for unauthenticated bootstrap (ml_mode, accel
-capability, feature flags); gate writes by auth/role.
-
----
-
-## 6. Effort Estimate
-
-**M (Medium).**
-- New DB table + migration for system config + user prefs JSON: S.
-- Backend: extend `routers/config.py` with authenticated GET/PUT for system + preferences,
- accel capability report (torch device detection), wire `USE_GPU` → tri-state: S–M.
-- Frontend: brand-new settings route + 6 accordion sections, forms wired to API: M (largest
- piece — no existing settings UI to extend).
-- Total ~M; the accel tri-state + capability endpoint is the only novel backend logic, the
- rest is CRUD + form plumbing. Scoping to the 6 groups (and skipping FFmpeg/SMTP/backup/etc.)
- keeps it out of L territory.
diff --git a/docs/overhaul/inventory/lane-h-mobile-desktop.md b/docs/overhaul/inventory/lane-h-mobile-desktop.md
deleted file mode 100644
index 35e30988..00000000
--- a/docs/overhaul/inventory/lane-h-mobile-desktop.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Lane H — Mobile & Desktop Client Surface (Foundation Planning)
-
-Read-only discovery. Reference = Immich-style Flutter app at `reference-app/mobile/`. Find = Tauri desktop shell + React/Next.js web UI, no mobile app yet. This is foundation-only (Phase 8) planning; nothing here is committed scope.
-
-## 1. Reference mobile feature surface
-
-Feature areas, enumerated from folder/file names (not implementation):
-
-- **Timeline / library** — main photo grid, folders, locked (private) area, partner sharing. `reference-app/mobile/lib/pages/library/` (`folder`, `locked`, `partner`, `shared_link`)
-- **Backup / upload** — album-selection backup, upload detail, backup options. `reference-app/mobile/lib/pages/backup/`
-- **Login / multi-server / auth** — login, change password. `reference-app/mobile/lib/pages/login/` + `services/auth.service.dart`, `oauth.service.dart`, `server_info.service.dart`
-- **Search (incl. map)** — search surface with map view. `reference-app/mobile/lib/pages/search/map/` + `services/search.service.dart`, `map.service.dart`
-- **Settings** — app settings, sync status, custom request headers. `reference-app/mobile/lib/pages/settings/` + `pages/common/headers_settings.page.dart`
-- **Share intent** — receive shared content from other apps. `reference-app/mobile/lib/pages/share_intent/` + `services/share_intent_service.dart`
-- **Shared links** — public/shared link viewing. `reference-app/mobile/lib/pages/library/shared_link/` + `services/shared_link.service.dart`
-- **Common shell** — tab shell, splash, app logs, download panel. `reference-app/mobile/lib/pages/common/`
-- **Cross-cutting services** — activity feed, downloads, people/faces, casting (gcast), local auth (biometric), localization, network/connectivity, home-screen widgets, deep links. `reference-app/mobile/lib/services/`
-
-## 2. Mobile-specific capabilities (future Find scope)
-
-Things only a native mobile app does — these have no desktop/web equivalent and would be net-new for Find:
-
-- **Background auto-backup / upload** of camera roll. `services/background_upload.service.dart`, `foreground_upload.service.dart`, `platform/background_worker_api.g.dart`
-- **Local device photo sync & native gallery access** — reading on-device albums/assets, native thumbnails. `platform/native_sync_api.g.dart`, `local_image_api.g.dart`, `thumbnail_api.g.dart`
-- **Device albums** — selecting which local albums to back up. `pages/backup/drift_backup_album_selection.page.dart`
-- **Share-to (OS share sheet ingest)** — receiving images from other apps. `pages/share_intent/`, `platform/view_intent_api.g.dart`
-- **OS integrations** — runtime permissions, connectivity awareness, biometric lock, home-screen widgets, casting. `platform/permission_api.g.dart`, `connectivity_api.g.dart`, `services/local_auth.service.dart`, `widget.service.dart`, `gcast.service.dart`
-
-These are the hard parts of any mobile client; the photo-browsing UI itself is comparatively cheap.
-
-## 3. Desktop (Tauri) path
-
-Find already has a working Tauri 2 shell that wraps the existing React/Next.js web UI — a desktop client is largely free:
-
-- `frontend/src-tauri/` confirmed: `tauri.conf.json` (productName "Find", `frontendDist: ../out`, `devUrl: localhost:3000`, `bundle.targets: all`), Rust `src/{main,lib}.rs`, `capabilities/default.json`, `icons/`, `gen/`, `build.rs`.
-- The web UI (React 19 / Next 16 / Tailwind / Zustand / TanStack Query / axios) renders inside the Tauri webview unchanged — same components serve web and desktop.
-
-What's still needed for a real desktop client (small, incremental):
-- Static export pipeline feeding `../out` (Next config / build step).
-- Tauri capability/permission tuning in `capabilities/default.json` for any native calls.
-- Desktop niceties: native file dialogs for upload, optional folder-watch / local import, auto-update, window/menu polish.
-- CI to bundle per-OS installers (`targets: all` already set).
-
-Verdict: desktop is a cheap win — reuse the web UI, add file/import affordances. No second codebase.
-
-## 4. Mobile spike recommendation — RN vs Flutter
-
-Find's entire stack is React/TypeScript (React 19, Zustand, TanStack Query, axios) and the desktop client already reuses those components. For a mobile spike I recommend **React Native (with Expo)** over Flutter. Rationale: RN lets us reuse the team's existing React/TS skills, share non-UI logic (API client, types, query/state layer, validation) with the web and Tauri apps, and keep one language across the whole product. Flutter would be a separate Dart codebase and skillset with near-zero shared code, despite the reference app being Flutter — copying its architecture is not a reason to adopt its language. The main RN caveat is that the mobile-specific hard parts (background upload, native gallery/album sync, share intents, biometric lock) lean on native modules; Expo covers media library, image picker, background tasks, and secure store, but background auto-backup is the one area where RN needs care and possibly custom native modules. That risk exists in any framework. Recommendation: a thin RN/Expo spike that proves login (multi-server), timeline browsing against Find's API, and a basic manual upload — defer background auto-backup to a later milestone.
-
-## 5. Effort estimate
-
-Foundation-only, Phase 8. Desktop (Tauri) is **small** — the shell exists and the web UI drops in; mostly export pipeline + file affordances + installer CI. Mobile is **large** if pursued fully: a new RN/Expo app, native modules for the device-sync/backup/share-intent capabilities in section 2, and parity work on timeline/search/albums/sharing. Recommend scoping the mobile effort as a narrow spike first (browse + manual upload) and treating background auto-backup as its own large follow-on.
diff --git a/docs/plans/not-started/desktop-runtime-comparison.md b/docs/plans/not-started/desktop-runtime-comparison.md
index 1fdd0f5f..26e87dc6 100644
--- a/docs/plans/not-started/desktop-runtime-comparison.md
+++ b/docs/plans/not-started/desktop-runtime-comparison.md
@@ -213,7 +213,7 @@
## 5. Configuration & Secrets
-### Current: Docker .env + docker-compose.yml
+### Current: Docker `.env` + modular `compose*.yml`
| Aspect | Details |
|--------|---------|
diff --git a/docs/plans/not-started/remote-acceleration.md b/docs/plans/not-started/remote-acceleration.md
index 51adeff2..7163ac1d 100644
--- a/docs/plans/not-started/remote-acceleration.md
+++ b/docs/plans/not-started/remote-acceleration.md
@@ -10,7 +10,7 @@
## 1. Problem Statement
-Find's local-first design means all ML inference — YOLOv10 object detection, Florence-2 captioning, PaddleOCR, and SigLIP ViT-B-16 embeddings — runs on the user's machine. The total model weight is approximately 1.5 GB and the `docker-compose.yml` requests an NVIDIA GPU with `count: 1`. This creates two concrete friction points:
+Find's local-first design means all ML inference — YOLO object detection, Florence-2 captioning, PaddleOCR, and SigLIP ViT-B-16 embeddings — runs on the user's machine. The default `compose.yml` requests an NVIDIA GPU, while `compose.cpu.yml`, `compose.mock.yml`, and `compose.no-ai.yml` provide modular non-CUDA choices. Remote acceleration remains optional and user-controlled.
- **GPU requirement:** Most consumer machines and all mobile devices cannot run the full ML stack at acceptable throughput.
- **Mobile access:** A mobile client has no practical path to local ML inference. Without a remote endpoint, a mobile client is read-only at best.
diff --git a/docs/plans/not-started/storage-provider-neutrality-adr.md b/docs/plans/not-started/storage-provider-neutrality-adr.md
index c19212e2..9f986d18 100644
--- a/docs/plans/not-started/storage-provider-neutrality-adr.md
+++ b/docs/plans/not-started/storage-provider-neutrality-adr.md
@@ -14,8 +14,8 @@ This note evaluates the current storage architecture only from:
- `backend/src/find_api/core/storage.py`
- `backend/src/find_api/core/config.py`
-- `docker-compose.yml`
-- `docker-compose.light.yml`
+- `compose.yml`
+- `compose.mock.yml`
- `docs/`
It does not propose a storage migration in this PR. The goal is to document the current coupling, compare realistic options, and recommend an architectural direction that keeps Find local-first.
@@ -371,8 +371,8 @@ After the abstraction lands, validate RustFS as the leading replacement candidat
- Current repository files reviewed in this ADR:
- `backend/src/find_api/core/storage.py`
- `backend/src/find_api/core/config.py`
- - `docker-compose.yml`
- - `docker-compose.light.yml`
+ - `compose.yml`
+ - `compose.mock.yml`
- MinIO GitHub repository archive notice: https://github.com/minio/minio
- RustFS SDK overview: https://docs.rustfs.com/developer/sdk/
- RustFS Docker installation docs: https://docs.rustfs.com/installation/docker/index.html
diff --git a/plan.md b/plan.md
index 9ba0a814..b1c9f339 100644
--- a/plan.md
+++ b/plan.md
@@ -1,8 +1,39 @@
# Find — Whole-Application Overhaul Plan
-> **Status:** DRAFT v2 — planning only. No feature code is written from this file yet.
-> **Branch:** `feat/app-overhaul` (cut from `origin/main`).
+> **Status:** v1.1 release candidate — implementation complete locally; final PR/release gates in progress.
+> **Branch:** `abhash/production-hardening` (synchronized with `origin/main`).
> **Reference codebase:** `./reference-app/` (local, gitignored, AGPL-3.0). Read-only reference used to learn UI/behavior; **its contents are never committed to Find**.
+
+## 2026-07-13 release-candidate addendum
+
+The local `reference-app/` checkout has been restored by the maintainer and is
+now intentionally retained as an ignored, read-only design/behavior reference.
+It is excluded from Docker contexts and CI rejects any tracked file below
+`reference-app/` or `@reference-app/`.
+
+- [x] The root route redirects to the timeline; the responsive application
+ shell, account/auth routes, settings, map, and vault are first-class routes.
+- [x] Gallery, search, albums, archive, trash, people/cluster media panels,
+ public shares, and vault use chronological timeline presentation rather than
+ independent masonry/grid scrolling.
+- [x] The private map is opt-in, stores scoped EXIF coordinates locally, uses a
+ bundled Natural Earth land layer, and makes no tile/geocoder requests.
+- [x] Vault sessions support explicit server lock, session-only decrypted
+ thumbnails, the shared viewer, and rollback-safe restore to normal storage.
+- [x] Settings control AI enablement, installed mock/full/disabled modes, and
+ CPU/GPU preference while reporting the artifact, applied mode, worker health,
+ and restart requirement.
+- [x] Build artifacts are split into `no-ai`, `mock`, `cpu`, and `nvidia`
+ Compose profiles so CPU/no-AI users do not install CUDA packages and NVIDIA
+ users do not install the CPU PyTorch wheel.
+- [x] Remote/BYOK inference stays fail-closed until a reviewed out-of-process
+ adapter exists; private media never silently falls back to a cloud service.
+
+Verification: frontend 253 tests and the 20-route production build pass; Tauri
+`cargo check --locked` passes at v1.1.0; uv lock and all five Compose
+configurations pass; the integrated backend suite passes with 513 tests and 5
+expected platform-specific skips. Real NVIDIA inference remains a
+hardware-specific release gate.
> **Nature of project:** Find is an **open-source** photo application. This plan reuses an open-source reference project's UI and code patterns under a compliant license (see §1).
> **Tracking:** every step is a checkbox with a status label. Update as work lands. Keep this file the single source of truth.
@@ -148,7 +179,8 @@ A **fast, lightweight, open-source** Find that:
- Ships a **settings panel** (modeled on the reference's settings UX) covering all configuration, including a **hardware-acceleration toggle**: use GPU when the system supports it, **fall back to CPU automatically** otherwise.
- Is fully **Find-branded** (no reference marks), under a **compliant license** (§1), with a React web UI, FastAPI(+selective Rust) backend, and **foundations** laid for desktop (Tauri) and native mobile (Flutter/RN spike).
- Keeps Find's niche/large ML models, while adopting the reference's faster models where they measurably win and licensing permits.
-- At the end, the local `reference-app/` is **removed and replaced with placeholder images**, confirming nothing is wholesale-copied (§Phase 9).
+- The local `reference-app/` remains an ignored, read-only reference checkout;
+ CI and Docker context guards prove the application never depends on it.
---
@@ -172,15 +204,9 @@ A **fast, lightweight, open-source** Find that:
### PHASE 1 — Discovery & Parity Inventory *(parallel readers)*
**Goal:** an exact, file-referenced map of what to build. *(~1 week, highly parallel)*
-- **Stage 1.1 — Feature inventory** *(lanes run concurrently)* — **DONE**; specs in `docs/overhaul/inventory/lane-*.md`
- - Lane A (Timeline/grid) · [x] completed — `inventory/lane-a-timeline-grid.md`. Gap: no justified layout, no scrubber, no time-bucket model.
- - Lane B (Albums/sharing) · [x] completed — `inventory/lane-b-albums-sharing.md`. Greenfield; share-link passwords must be hashed (ref stores plaintext).
- - Lane C (Archive/favorites/trash) · [x] completed — `inventory/lane-c-archive-favorites-trash.md`. Needs `is_archived`+`deleted_at`; favorites = existing `liked`.
- - Lane D (Slideshow/viewer) · [x] completed — `inventory/lane-d-viewer-slideshow.md`. Find viewer is a metadata dialog; needs zoom/pan/progressive-load/slideshow.
- - Lane E (Backend/API) · [x] completed — `inventory/lane-e-backend-api.md`. Timeline bucket contract captured; albums/sharing/trash domains absent.
- - Lane F (ML) · [x] completed — `inventory/lane-f-ml.md`. Adopt ONNX EP-fallback + CPU-light variants; keep Find's niche models.
- - Lane G (Settings/config) · [x] completed — `inventory/lane-g-settings.md`. 6 setting groups; accel toggle lives in ML group; Find has only `USE_GPU` env bool.
- - Lane H (Mobile/desktop) · [x] completed — `inventory/lane-h-mobile-desktop.md`. Tauri reuse is cheap; recommend RN+Expo for mobile spike.
+- **Stage 1.1 — Feature inventory** — **DONE**. The eight temporary lane
+ reports were consolidated into `docs/overhaul/inventory/parity-matrix.md`
+ and removed during the v1.1 repository cleanup.
- **Stage 1.2 — Gap analysis & sequencing** — [x] completed
- [x] completed — Parity matrix consolidated in `docs/overhaul/inventory/parity-matrix.md` (Appendix §C points to it).
- [x] completed — Build sequence ordered (parity-matrix §C.5): backend foundation (timeline contract + asset-state) first, then design system, timeline UI, viewer, albums/sharing, settings/accel, ML, Rust, clients.
@@ -294,7 +320,9 @@ A **fast, lightweight, open-source** Find that:
- **Stage 9.1 — Reference removal** · Owner: ___
- [x] completed — Confirm no reference source is committed (`git ls-files | grep -i` checks); confirm derived files carry attribution (Path A). *verified: `git ls-files | grep -iE 'reference-app|immich'` → none; `reference-app/` is gitignored; the 6 genuinely-ported pure modules (justified-layout, timeline-scrubber, viewer-zoom/preload, slideshow, asset-viewer) carry the AGPL attribution header. New backend routers are original Find code (no verbatim port).*
- - [x] completed — **Deleted local `reference-app/`** (gitignored, ~437MB, ~3865 files) per user authorization. **Independence proven by CI**: run 28397342197 is fully green on a checkout that never contained `reference-app/` (it's gitignored, so CI builds + tests Find without it) — a stronger proof of "nothing wholesale-copied" than a local placeholder swap. The literal "replace with placeholder images" step is N/A: Find never imported reference image assets (9.1 first box verified no reference source was committed), so there are no copied assets to placeholder-replace. The only tracked mentions of the folder are `.gitignore` (the ignore rule) and `NOTICE` (AGPL attribution credit) — neither a runtime dependency.
+ - [x] completed — `reference-app/` is ignored, excluded from Docker contexts,
+ and rejected by CI if tracked. Find builds and tests without the local
+ checkout, which may remain available for read-only parity audits.
- **Stage 9.2 — Feature integration** · Owner: ___ — [x] completed — Wire all phases together on one running build; resolve cross-lane seams; everything reachable from the new UI. *verified: CI run 28397342197 green end-to-end — `next build` produces all routes, backend boots with all routers registered (`partner` added → import smoke OK), browser E2E confirms the shell + new feature routes (Timeline/Albums/Archive/Trash/Settings) render and are reachable from the nav. All overhaul features are wired into one integrated build on `feat/app-overhaul`.*
- **Stage 9.3 — Compliance close-out** · Owner: ___ — [x] completed — Verify §1 license/attribution obligations satisfied; verify name-scrub CI is green.
- [x] completed — §1 attribution obligations verified (see 9.1: no reference source committed, derived files attributed, Find is AGPL-3.0 per §G). Reference copy now also **removed locally** (9.1) with independence proven by a green CI run on a reference-free checkout. Fixed a shipped-UI compliance bug: the footer said "MIT License" — corrected to "AGPL-3.0 License" to match the relicense. *(Name-scrub CI intentionally not built — see opening note: it would enforce stripping upstream attribution, contrary to AGPL; attribution is credited in NOTICE instead. The factual check "no reference source committed" passes.)*
@@ -366,11 +394,12 @@ Every feature lane owns its tests; the program owns the final gate (§Phase 10).
- [~] **Settings panel** — panel shipped with the hardware-accel group wired to a live backend **and persisted server-side** (`/api/settings`, §5.1). Remaining groups (library/storage/ML/appearance/advanced) are YAGNI-deferred until their backends exist — not a parity gap, a scope choice.
- [~] **Hardware acceleration** — `Auto/GPU/CPU` resolution + **auto CPU fallback** implemented, unit-tested, wired into all ML inference, and **verified green on a real ubuntu+macOS+windows CI matrix** (run 28392485314). Remaining (owner-delegated): a real-GPU runner (non-fallback path) + Android/NNAPI on-device.
- [ ] **Speed** — layout + timeline query hot paths **measured and within budget** (Appendix §E: layout ~12 ms, buckets ~19.5 ms, bucket ~19.7 ms — API-level). Real-hardware / low-end-profile budgets (first paint, scroll-to-date, thumbnail throughput, CPU-mode ML latency) **owner-delegated** — need a live stack + seeded library.
-- [~] **All tests green** — unit + integration + component **all green and CI-gated** (backend **458** / frontend **215**; `tsc` + `ruff` + `biome` clean; `next build` succeeds; CI run 28397342197 all-green incl. cross-OS matrix). **API-level E2E journey green** + **browser E2E (Playwright) built, passing, and CI-gated on a real runner** (§10.2). Full data-path browser journeys against seeded data remain (owner-delegated, need a live stack).
+- [~] **All tests green** — the integrated v1.1 candidate passes backend **513 passed / 5 skipped** and frontend **253 passed**, plus Ruff, Biome, TypeScript, the 20-route Next.js production build, five Compose parses, and Tauri `cargo check --locked`. Full data-path browser journeys against seeded data remain owner-delegated because they need a live stack.
- [~] **Accessibility + security** — automated a11y smoke tests green; sharing/crypto + album/asset-state + **partner-sharing** all `/security-review`'d (each found+fixed real issues, re-reviewed RESOLVED). Timeline scrubber made keyboard-operable (CI-gated by Biome a11y). **Manual screen-reader pass owner-delegated** (human — §10.5).
- [x] **License compliant (Path A)** — Find is AGPL-3.0; `LICENSE`/`NOTICE`/metadata correct; shipped-UI footer corrected MIT→AGPL-3.0 (§9.3/§G).
- [x] **Name scrubbed** — current tree clean. *(Name-scrub CI intentionally NOT built — it would enforce stripping upstream attribution, conflicting with AGPL; attribution credited in NOTICE instead.)*
-- [x] **Reference removed** — local `reference-app/` deleted (§9.1); independence proven by a green CI run on a reference-free checkout.
+- [x] **Reference isolated** — the local checkout is ignored and optional;
+ independence is proven by CI on reference-free checkouts.
- [x] **Docs shipped** — hardware-accel guide ✅, features guide ✅, migration notes ✅, changelog ✅ — all linked from `docs/index.md` (§9.4).
- [~] **Shipped** — **authorized by the owner.** Branch pushed; PR #321 open and **fully CI-green**. Merge to `main` + tag in progress (§10.6) — this pass.
@@ -409,8 +438,7 @@ lays out from `ratio` alone, blur-up is the only thing gated on it).
| _seed at Phase 0.3_ | | | | |
### §C — Parity Matrix (have / partial / missing)
-Consolidated in `docs/overhaul/inventory/parity-matrix.md` (with per-lane detail in
-`docs/overhaul/inventory/lane-*.md`). Key takeaways:
+Consolidated in `docs/overhaul/inventory/parity-matrix.md`. Key takeaways:
- The **timeline-bucket contract** (`/timeline/buckets` + `/timeline/bucket`, columnar, with
per-asset `ratio`+`thumbhash`) is the long pole — Phase 3 UI consumes it, so it ships first.
- `media` needs `is_archived` + `deleted_at`; favorites already exist as `liked`. A single
From 853b593a19d09c45b020a2176d3daf96bf619cb1 Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Mon, 13 Jul 2026 02:26:11 +0530
Subject: [PATCH 06/21] fix: align runtime contracts and privacy guidance
---
.env.example | 3 ++-
frontend/src/lib/api.ts | 9 +++++++--
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/.env.example b/.env.example
index 43a8aa58..28c240e1 100644
--- a/.env.example
+++ b/.env.example
@@ -75,7 +75,8 @@ ML_MODE=full
# and workers read it at the start of every job.
AI_ENABLED=true
# Opt in to retaining GPS coordinates from EXIF for the private map. Workers
-# also read this at each job boundary. False drops/clears stored coordinates.
+# also read this at each job boundary. False prevents/clears coordinates when
+# an image is processed; reprocess existing media to remove previously saved GPS.
MAP_ENABLED=false
# Remote ML/BYOK fields are reserved for a future self-hosted adapter. Today,
# ML_MODE=remote is reported as unavailable and never falls back to local AI.
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 31dcc7e0..093c2dad 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -211,8 +211,13 @@ export interface JobStatus {
}
export interface AppConfig {
- ml_mode: "disabled" | "full" | "mock" | "remote";
- accel_mode?: AccelMode;
+ ml_mode: "disabled" | "full" | "mock" | "unavailable";
+ configured_ml_mode: "disabled" | "full" | "mock" | "remote";
+ accel_mode: AccelMode;
+ ai_enabled: boolean;
+ map_enabled: boolean;
+ build_profile: "no-ai" | "mock" | "cpu" | "nvidia" | "development" | string;
+ supported_ml_modes: string[];
}
export interface AccountUser {
From 361a002b91eada1d942cc677e172d41bd67b5c2d Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Mon, 13 Jul 2026 03:24:50 +0530
Subject: [PATCH 07/21] fix: restore media previews and Florence AI
---
backend/pyproject.toml | 4 +-
backend/src/find_api/ml/captioner.py | 4 +-
backend/uv.lock | 82 +++++++------------
frontend/src/__tests__/map-page.test.tsx | 4 +-
frontend/src/app/gallery/page.tsx | 5 +-
frontend/src/app/timeline/page.tsx | 13 ++-
frontend/src/components/asset-viewer.tsx | 1 +
.../src/components/map-timeline-panel.tsx | 18 +++-
frontend/src/components/private-map.tsx | 5 +-
.../src/components/timeline-media-view.tsx | 17 +++-
10 files changed, 82 insertions(+), 71 deletions(-)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 0575ffb2..3b8f9e83 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -36,7 +36,7 @@ mock = [
cpu = [
"torch==2.8.0",
"torchvision==0.23.0",
- "transformers>=5.3.0,<6",
+ "transformers==4.49.0",
"open-clip-torch>=2.24.0",
"opencv-python-headless==4.9.0.80",
"ultralytics>=8.2.0",
@@ -52,7 +52,7 @@ cpu = [
nvidia = [
"torch==2.8.0",
"torchvision==0.23.0",
- "transformers>=5.3.0,<6",
+ "transformers==4.49.0",
"open-clip-torch>=2.24.0",
"opencv-python-headless==4.9.0.80",
"ultralytics>=8.2.0",
diff --git a/backend/src/find_api/ml/captioner.py b/backend/src/find_api/ml/captioner.py
index 96179bed..251425e4 100644
--- a/backend/src/find_api/ml/captioner.py
+++ b/backend/src/find_api/ml/captioner.py
@@ -3,7 +3,7 @@
"""
import torch
-from transformers import AutoProcessor, AutoModelForCausalLM
+from transformers import AutoModelForCausalLM, AutoProcessor
from PIL import Image
import numpy as np
from typing import Union
@@ -35,7 +35,7 @@ def _load_model(self):
model = AutoModelForCausalLM.from_pretrained(
model_id,
trust_remote_code=True,
- dtype=torch_dtype,
+ torch_dtype=torch_dtype,
attn_implementation="eager",
).to(device)
diff --git a/backend/uv.lock b/backend/uv.lock
index b9bda734..51e212b5 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -672,8 +672,8 @@ requires-dist = [
{ name = "torch", marker = "extra == 'nvidia'", specifier = "==2.8.0", index = "https://download.pytorch.org/whl/cu128", conflict = { package = "find-backend", extra = "nvidia" } },
{ name = "torchvision", marker = "extra == 'cpu'", specifier = "==0.23.0", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "find-backend", extra = "cpu" } },
{ name = "torchvision", marker = "extra == 'nvidia'", specifier = "==0.23.0", index = "https://download.pytorch.org/whl/cu128", conflict = { package = "find-backend", extra = "nvidia" } },
- { name = "transformers", marker = "extra == 'cpu'", specifier = ">=5.3.0,<6" },
- { name = "transformers", marker = "extra == 'nvidia'", specifier = ">=5.3.0,<6" },
+ { name = "transformers", marker = "extra == 'cpu'", specifier = "==4.49.0" },
+ { name = "transformers", marker = "extra == 'nvidia'", specifier = "==4.49.0" },
{ name = "ultralytics", marker = "extra == 'cpu'", specifier = ">=8.2.0" },
{ name = "ultralytics", marker = "extra == 'nvidia'", specifier = ">=8.2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.43.0,<0.44" },
@@ -833,23 +833,21 @@ wheels = [
[[package]]
name = "huggingface-hub"
-version = "1.21.0"
+version = "0.36.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "click" },
{ name = "filelock" },
{ name = "fsspec" },
- { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
- { name = "httpx" },
+ { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
{ name = "packaging" },
{ name = "pyyaml" },
+ { name = "requests" },
{ name = "tqdm" },
- { name = "typer" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/8f/77/ce3331f40cb2d021fe9b24c46c41e72faf74493621138e5eddac12bf5e1c/huggingface_hub-1.21.0.tar.gz", hash = "sha256:a44f222cd8f2f7c2eade30b5e7a04cac984a3235fa61ea87a0a5a31db77d561f", size = 861572, upload-time = "2026-06-25T13:09:26.356Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl", hash = "sha256:eadaa3678c512c82aea69e8675d90a184861e68de32f1105668628b4dce0e7cd", size = 721078, upload-time = "2026-06-25T13:09:24.402Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
]
[[package]]
@@ -2343,15 +2341,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" },
]
-[[package]]
-name = "shellingham"
-version = "1.5.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
-]
-
[[package]]
name = "six"
version = "1.17.0"
@@ -2470,28 +2459,27 @@ wheels = [
[[package]]
name = "tokenizers"
-version = "0.22.2"
+version = "0.21.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c2/2f/402986d0823f8d7ca139d969af2917fefaa9b947d1fb32f6168c509f2492/tokenizers-0.21.4.tar.gz", hash = "sha256:fa23f85fbc9a02ec5c6978da172cdcbac23498c3ca9f3645c5c68740ac007880", size = 351253, upload-time = "2025-07-28T15:48:54.325Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
- { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
- { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
- { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
- { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
- { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
- { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
- { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
- { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
- { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
- { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
- { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
- { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
- { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
- { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c6/fdb6f72bf6454f52eb4a2510be7fb0f614e541a2554d6210e370d85efff4/tokenizers-0.21.4-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ccc10a7c3bcefe0f242867dc914fc1226ee44321eb618cfe3019b5df3400133", size = 2863987, upload-time = "2025-07-28T15:48:44.877Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/a6/28975479e35ddc751dc1ddc97b9b69bf7fcf074db31548aab37f8116674c/tokenizers-0.21.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:5e2f601a8e0cd5be5cc7506b20a79112370b9b3e9cb5f13f68ab11acd6ca7d60", size = 2732457, upload-time = "2025-07-28T15:48:43.265Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/8f/24f39d7b5c726b7b0be95dca04f344df278a3fe3a4deb15a975d194cbb32/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b376f5a1aee67b4d29032ee85511bbd1b99007ec735f7f35c8a2eb104eade5", size = 3012624, upload-time = "2025-07-28T13:22:43.895Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/26358925717687a58cb74d7a508de96649544fad5778f0cd9827398dc499/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2107ad649e2cda4488d41dfd031469e9da3fcbfd6183e74e4958fa729ffbf9c6", size = 2939681, upload-time = "2025-07-28T13:22:47.499Z" },
+ { url = "https://files.pythonhosted.org/packages/99/6f/cc300fea5db2ab5ddc2c8aea5757a27b89c84469899710c3aeddc1d39801/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c73012da95afafdf235ba80047699df4384fdc481527448a078ffd00e45a7d9", size = 3247445, upload-time = "2025-07-28T15:48:39.711Z" },
+ { url = "https://files.pythonhosted.org/packages/be/bf/98cb4b9c3c4afd8be89cfa6423704337dc20b73eb4180397a6e0d456c334/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f23186c40395fc390d27f519679a58023f368a0aad234af145e0f39ad1212732", size = 3428014, upload-time = "2025-07-28T13:22:49.569Z" },
+ { url = "https://files.pythonhosted.org/packages/75/c7/96c1cc780e6ca7f01a57c13235dd05b7bc1c0f3588512ebe9d1331b5f5ae/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc88bb34e23a54cc42713d6d98af5f1bf79c07653d24fe984d2d695ba2c922a2", size = 3193197, upload-time = "2025-07-28T13:22:51.471Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/90/273b6c7ec78af547694eddeea9e05de771278bd20476525ab930cecaf7d8/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51b7eabb104f46c1c50b486520555715457ae833d5aee9ff6ae853d1130506ff", size = 3115426, upload-time = "2025-07-28T15:48:41.439Z" },
+ { url = "https://files.pythonhosted.org/packages/91/43/c640d5a07e95f1cf9d2c92501f20a25f179ac53a4f71e1489a3dcfcc67ee/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:714b05b2e1af1288bd1bc56ce496c4cebb64a20d158ee802887757791191e6e2", size = 9089127, upload-time = "2025-07-28T15:48:46.472Z" },
+ { url = "https://files.pythonhosted.org/packages/44/a1/dd23edd6271d4dca788e5200a807b49ec3e6987815cd9d0a07ad9c96c7c2/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1340ff877ceedfa937544b7d79f5b7becf33a4cfb58f89b3b49927004ef66f78", size = 9055243, upload-time = "2025-07-28T15:48:48.539Z" },
+ { url = "https://files.pythonhosted.org/packages/21/2b/b410d6e9021c4b7ddb57248304dc817c4d4970b73b6ee343674914701197/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3c1f4317576e465ac9ef0d165b247825a2a4078bcd01cba6b54b867bdf9fdd8b", size = 9298237, upload-time = "2025-07-28T15:48:50.443Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/0a/42348c995c67e2e6e5c89ffb9cfd68507cbaeb84ff39c49ee6e0a6dd0fd2/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c212aa4e45ec0bb5274b16b6f31dd3f1c41944025c2358faaa5782c754e84c24", size = 9461980, upload-time = "2025-07-28T15:48:52.325Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/d3/dacccd834404cd71b5c334882f3ba40331ad2120e69ded32cf5fda9a7436/tokenizers-0.21.4-cp39-abi3-win32.whl", hash = "sha256:6c42a930bc5f4c47f4ea775c91de47d27910881902b0f20e4990ebe045a415d0", size = 2329871, upload-time = "2025-07-28T15:48:56.841Z" },
+ { url = "https://files.pythonhosted.org/packages/41/f2/fd673d979185f5dcbac4be7d09461cbb99751554ffb6718d0013af8604cb/tokenizers-0.21.4-cp39-abi3-win_amd64.whl", hash = "sha256:475d807a5c3eb72c59ad9b5fcdb254f6e17f53dfcbb9903233b0dfa9c943b597", size = 2507568, upload-time = "2025-07-28T15:48:55.456Z" },
]
[[package]]
@@ -2659,22 +2647,23 @@ wheels = [
[[package]]
name = "transformers"
-version = "5.3.0"
+version = "4.49.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "filelock" },
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "regex" },
+ { name = "requests" },
{ name = "safetensors" },
{ name = "tokenizers" },
{ name = "tqdm" },
- { name = "typer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/50/46573150944f46df8ec968eda854023165a84470b42f69f67c7d475dabc5/transformers-4.49.0.tar.gz", hash = "sha256:7e40e640b5b8dc3f48743f5f5adbdce3660c82baafbd3afdfc04143cdbd2089e", size = 8610952, upload-time = "2025-02-17T15:19:03.614Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" },
+ { url = "https://files.pythonhosted.org/packages/20/37/1f29af63e9c30156a3ed6ebc2754077016577c094f31de7b2631e5d379eb/transformers-4.49.0-py3-none-any.whl", hash = "sha256:6b4fded1c5fee04d384b1014495b4235a2b53c87503d7d592423c06128cbbe03", size = 9970275, upload-time = "2025-02-17T15:18:58.814Z" },
]
[[package]]
@@ -2688,21 +2677,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/66/b1eb52839f563623d185f0927eb3530ee4d5ffe9d377cdaf5346b306689e/triton-3.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c1d84a5c0ec2c0f8e8a072d7fd150cab84a9c239eaddc6706c081bfae4eb04", size = 155560068, upload-time = "2025-07-30T19:58:37.081Z" },
]
-[[package]]
-name = "typer"
-version = "0.25.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "annotated-doc" },
- { name = "click" },
- { name = "rich" },
- { name = "shellingham" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
-]
-
[[package]]
name = "typing-extensions"
version = "4.15.0"
diff --git a/frontend/src/__tests__/map-page.test.tsx b/frontend/src/__tests__/map-page.test.tsx
index 2570cf35..570a64bc 100644
--- a/frontend/src/__tests__/map-page.test.tsx
+++ b/frontend/src/__tests__/map-page.test.tsx
@@ -154,7 +154,9 @@ describe("MapPage", () => {
fireEvent.click(screen.getByTestId("map-timeline-photo-1"));
expect(screen.getByTestId("asset-viewer")).toBeInTheDocument();
- await waitFor(() => expect(preloaded).toContain("/api/image/1/original"));
+ await waitFor(() =>
+ expect(preloaded).toContain("http://localhost:8000/api/image/1/original"),
+ );
});
it("renders a useful empty state without hiding the offline world map", async () => {
diff --git a/frontend/src/app/gallery/page.tsx b/frontend/src/app/gallery/page.tsx
index 4f09ec31..61f983e1 100644
--- a/frontend/src/app/gallery/page.tsx
+++ b/frontend/src/app/gallery/page.tsx
@@ -1314,7 +1314,10 @@ function GalleryPageContent() {
/>
({
id: a.id,
- thumbnailUrl: a.thumbnailUrl,
- originalUrl: `/api/image/${a.id}/original`,
+ thumbnailUrl:
+ resolveMediaUrl(a.thumbnailUrl, null, a.id, true) ?? a.thumbnailUrl,
+ originalUrl:
+ resolveMediaUrl(`/api/image/${a.id}/original`) ??
+ `/api/image/${a.id}/original`,
alt: a.createdAt
? `Photo from ${new Date(a.createdAt).toLocaleDateString()}`
: `Photo ${a.id}`,
@@ -254,7 +258,10 @@ export default function TimelinePage() {
>
{/* biome-ignore lint/performance/noImgElement: authenticated API thumbnail */}
timelineMarkers.map((marker) => ({
id: marker.id,
- thumbnailUrl: marker.thumbnail_url,
- originalUrl: `/api/image/${marker.id}/original`,
+ thumbnailUrl:
+ resolveMediaUrl(marker.thumbnail_url, null, marker.id, true) ??
+ marker.thumbnail_url,
+ originalUrl:
+ resolveMediaUrl(`/api/image/${marker.id}/original`) ??
+ `/api/image/${marker.id}/original`,
alt: marker.filename,
})),
[timelineMarkers],
@@ -92,7 +97,14 @@ export function MapTimelinePanel({ markers, onClose }: MapTimelinePanelProps) {
>
{/* biome-ignore lint/performance/noImgElement: authenticated media is served by the local API. */}
{
items: T[];
viewerAssets: ViewerAsset[];
@@ -133,14 +138,17 @@ export function TimelineMediaView({
const viewerAssets = useMemo(
() =>
timelineItems.map((item) => {
- const thumbnailUrl = getThumbnailUrl(item) ?? "";
+ const id = getId(item);
+ const rawThumbnailUrl = getThumbnailUrl(item);
+ const thumbnailUrl = resolveApiRoute(rawThumbnailUrl) ?? "";
+ const rawOriginalUrl = getOriginalUrl(item);
return {
- id: getId(item),
+ id,
thumbnailUrl,
alt: getAlt(item),
// View-only shares deliberately fall back to their share-scoped
// thumbnail; a private route is never synthesized here.
- originalUrl: getOriginalUrl(item) ?? thumbnailUrl,
+ originalUrl: resolveApiRoute(rawOriginalUrl) ?? thumbnailUrl,
};
}),
[getAlt, getId, getOriginalUrl, getThumbnailUrl, timelineItems],
@@ -305,7 +313,8 @@ export function TimelineMediaView({
getKey={({ item }) => getId(item)}
renderItem={({ item }, index) => {
const timelineIndex = groupStartIndex + index;
- const thumbnailUrl = getThumbnailUrl(item);
+ const rawThumbnailUrl = getThumbnailUrl(item);
+ const thumbnailUrl = resolveApiRoute(rawThumbnailUrl);
return (
Date: Mon, 13 Jul 2026 04:07:38 +0530
Subject: [PATCH 08/21] fix: secure the full AI caption pipeline
---
.env.example | 2 +-
README.md | 8 +-
backend/pyproject.toml | 4 +-
backend/src/find_api/core/config.py | 2 +-
backend/src/find_api/ml/captioner.py | 83 +++++-------------
backend/src/find_api/workers/jobs.py | 2 +-
backend/tests/test_status.py | 4 +-
backend/uv.lock | 115 +++++++++++++++----------
docs/guides/hardware-acceleration.md | 2 +-
docs/guides/real-ml-troubleshooting.md | 2 +-
10 files changed, 106 insertions(+), 118 deletions(-)
diff --git a/.env.example b/.env.example
index 28c240e1..b336be9a 100644
--- a/.env.example
+++ b/.env.example
@@ -106,7 +106,7 @@ ML_OFFLINE_ONLY=false
RQ_WORKER_CLASS=rq.worker.worker_classes.SimpleWorker
CLIP_MODEL=ViT-B-16-SigLIP
CLIP_PRETRAINED=webli
-BLIP_MODEL=microsoft/Florence-2-base
+BLIP_MODEL=Salesforce/blip-image-captioning-base
YOLO_MODEL=yolo26n.pt
USE_GPU=true
YOLO_HALF=true
diff --git a/README.md b/README.md
index 3a91196f..9003c237 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ See the documentation index in [`docs/index.md`](./docs/index.md), the mobile di
- **Frontend:** Next.js 16, React 19, React Query, Tailwind CSS, Biome
- **Backend:** FastAPI, SQLAlchemy, PostgreSQL + pgvector, Redis, RQ, MinIO
-- **ML pipeline:** YOLO26 nano, Florence-2 base, PaddleOCR, SigLIP (`open-clip`), InsightFace, HDBSCAN
+- **ML pipeline:** YOLO26 nano, BLIP image captioning, PaddleOCR, SigLIP (`open-clip`), InsightFace, HDBSCAN
## Runtime profiles
@@ -142,7 +142,7 @@ For UI, API, upload, gallery, search, clustering, docs, and workflow changes, us
docker compose -f compose.mock.yml up --build
```
-This runs the same app flow with `ML_MODE=mock`, a Python slim backend image, and no GPU/model cache mount. It avoids downloading Florence-2, SigLIP, PaddleOCR, YOLO, CUDA PyTorch, and related model weights, so first-time setup is much smaller and faster.
+This runs the same app flow with `ML_MODE=mock`, a Python slim backend image, and no GPU/model cache mount. It avoids downloading BLIP, SigLIP, PaddleOCR, YOLO, CUDA PyTorch, and related model weights, so first-time setup is much smaller and faster.
Light mode is deterministic but not AI-accurate:
@@ -187,7 +187,7 @@ Because mock vectors have no semantic content, search results are meaningless
docker compose up --build
```
-The worker loads Florence-2 (captioning), YOLOv10 (object detection), PaddleOCR (text extraction), and SigLIP via `open-clip` (semantic embeddings). All metadata and vectors reflect real model output.
+The worker loads BLIP (captioning), YOLOv10 (object detection), PaddleOCR (text extraction), and SigLIP via `open-clip` (semantic embeddings). All metadata and vectors reflect real model output.
**Full ML mode is required when you are working on or reporting:**
@@ -213,7 +213,7 @@ The worker loads Florence-2 (captioning), YOLOv10 (object detection), PaddleOCR
| OCR missed text | ❌ No | ✅ Required |
| ML pipeline performance | ❌ No | ✅ Required |
-First run of the full stack downloads Florence-2, SigLIP, PaddleOCR, and YOLO weights (several GB). Models are cached in the `model_cache` Docker volume and reused on subsequent runs.
+First run of the full stack downloads BLIP, SigLIP, PaddleOCR, and YOLO weights (several GB). Models are cached in the `model_cache` Docker volume and reused on subsequent runs.
## Controlling AI from the dashboard
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 3b8f9e83..afe982ce 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -36,7 +36,7 @@ mock = [
cpu = [
"torch==2.8.0",
"torchvision==0.23.0",
- "transformers==4.49.0",
+ "transformers==5.3.0",
"open-clip-torch>=2.24.0",
"opencv-python-headless==4.9.0.80",
"ultralytics>=8.2.0",
@@ -52,7 +52,7 @@ cpu = [
nvidia = [
"torch==2.8.0",
"torchvision==0.23.0",
- "transformers==4.49.0",
+ "transformers==5.3.0",
"open-clip-torch>=2.24.0",
"opencv-python-headless==4.9.0.80",
"ultralytics>=8.2.0",
diff --git a/backend/src/find_api/core/config.py b/backend/src/find_api/core/config.py
index 9d17d355..793734e0 100644
--- a/backend/src/find_api/core/config.py
+++ b/backend/src/find_api/core/config.py
@@ -69,7 +69,7 @@ class Settings(BaseSettings):
ML_OFFLINE_ONLY: bool = False
CLIP_MODEL: str = "ViT-B-16-SigLIP"
CLIP_PRETRAINED: str = "webli"
- BLIP_MODEL: str = "microsoft/Florence-2-base"
+ BLIP_MODEL: str = "Salesforce/blip-image-captioning-base"
YOLO_MODEL: str = "yolo26n.pt"
USE_GPU: bool = False
YOLO_HALF: bool = True
diff --git a/backend/src/find_api/ml/captioner.py b/backend/src/find_api/ml/captioner.py
index 251425e4..3c5538a4 100644
--- a/backend/src/find_api/ml/captioner.py
+++ b/backend/src/find_api/ml/captioner.py
@@ -1,9 +1,7 @@
-"""
-Image captioning using Florence-2
-"""
+"""Local image captioning using the native BLIP model implementation."""
import torch
-from transformers import AutoModelForCausalLM, AutoProcessor
+from transformers import BlipForConditionalGeneration, BlipProcessor
from PIL import Image
import numpy as np
from typing import Union
@@ -18,7 +16,7 @@
class ImageCaptioner:
- """Generate natural language captions for images using Florence-2"""
+ """Generate natural-language captions with a local BLIP checkpoint."""
def __init__(self):
self.manager = get_model_manager()
@@ -27,19 +25,21 @@ def __init__(self):
def _load_model(self):
"""Loader function for ModelManager"""
model_id = settings.BLIP_MODEL
- logger.info("Loading Florence-2 model: %s", model_id)
+ logger.info("Loading BLIP caption model: %s", model_id)
device = current_torch_device()
torch_dtype = torch.float16 if device == "cuda" else torch.float32
- model = AutoModelForCausalLM.from_pretrained(
+ model = BlipForConditionalGeneration.from_pretrained(
model_id,
- trust_remote_code=True,
- torch_dtype=torch_dtype,
- attn_implementation="eager",
+ trust_remote_code=False,
+ dtype=torch_dtype,
).to(device)
-
- processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
+ processor = BlipProcessor.from_pretrained(
+ model_id,
+ trust_remote_code=False,
+ use_fast=False,
+ )
return {
"model": model,
@@ -66,17 +66,14 @@ def generate_caption(
config_key = f"model={settings.BLIP_MODEL}|accel={current_accel_mode()}"
with self.manager.use_model(
- "florence-2", self._load_model, config_key=config_key
+ "captioner", self._load_model, config_key=config_key
) as bundle:
model = bundle["model"]
processor = bundle["processor"]
device = bundle["device"]
dtype = bundle["dtype"]
- # Florence-2 uses task prompts
- task_prompt = ""
-
- inputs = processor(text=task_prompt, images=image, return_tensors="pt")
+ inputs = processor(images=image, return_tensors="pt")
inputs = {
k: v.to(device, dtype)
if v.dtype == torch.float32 or v.dtype == torch.float16
@@ -87,7 +84,6 @@ def generate_caption(
# Generate
with torch.inference_mode():
generated_ids = model.generate(
- input_ids=inputs["input_ids"],
pixel_values=inputs["pixel_values"],
max_new_tokens=max_length,
num_beams=num_beams,
@@ -95,18 +91,9 @@ def generate_caption(
use_cache=True,
)
- generated_text = processor.batch_decode(
- generated_ids, skip_special_tokens=False
- )[0]
-
- # Post-process
- parsed_answer = processor.post_process_generation(
- generated_text,
- task=task_prompt,
- image_size=(image.width, image.height),
- )
-
- caption = parsed_answer.get(task_prompt, "")
+ caption = processor.decode(
+ generated_ids[0], skip_special_tokens=True
+ ).strip()
logger.info(f"Generated caption: {caption[:50]}...")
return caption
@@ -130,26 +117,14 @@ def generate_conditional_caption(
config_key = f"model={settings.BLIP_MODEL}|accel={current_accel_mode()}"
with self.manager.use_model(
- "florence-2", self._load_model, config_key=config_key
+ "captioner", self._load_model, config_key=config_key
) as bundle:
model = bundle["model"]
processor = bundle["processor"]
device = bundle["device"]
dtype = bundle["dtype"]
- # For VQA or specific prompts
- # For VQA or specific prompts
- # task_prompt = "" # Fallback or use prompt as VQA?
- # Florence-2 supports prompt
- # If prompt is a question, use
- # But for general conditional captioning, maybe just append?
- # Let's assume prompt is a question or task for now
-
- full_prompt = (
- f"{prompt}" if "?" in prompt else f"{prompt}"
- )
-
- inputs = processor(text=full_prompt, images=image, return_tensors="pt")
+ inputs = processor(text=prompt, images=image, return_tensors="pt")
inputs = {
k: v.to(device, dtype)
if v.dtype == torch.float32 or v.dtype == torch.float16
@@ -166,23 +141,9 @@ def generate_conditional_caption(
use_cache=True,
)
- generated_text = processor.batch_decode(
- generated_ids, skip_special_tokens=False
- )[0]
-
- # We might need manual parsing if post_process doesn't handle custom prompts well
- # But let's try standard
- caption = processor.post_process_generation(
- generated_text,
- task="",
- image_size=(image.width, image.height),
- )
-
- # If it returns dict
- if isinstance(caption, dict):
- caption = list(caption.values())[0]
-
- return str(caption)
+ return processor.decode(
+ generated_ids[0], skip_special_tokens=True
+ ).strip()
except Exception as e:
logger.error(f"Failed to generate conditional caption: {e}")
diff --git a/backend/src/find_api/workers/jobs.py b/backend/src/find_api/workers/jobs.py
index 6556f858..db3c4f71 100644
--- a/backend/src/find_api/workers/jobs.py
+++ b/backend/src/find_api/workers/jobs.py
@@ -44,7 +44,7 @@
logger.error(f"Failed to start model cleanup thread in worker: {e}")
FACE_CLUSTER_NAME_MATCH_THRESHOLD = 0.72
-ANALYSIS_MODEL_NAMES = ("yolo", "florence-2", "paddleocr", "siglip", "insightface")
+ANALYSIS_MODEL_NAMES = ("yolo", "captioner", "paddleocr", "siglip", "insightface")
def _begin_worker_runtime(db):
diff --git a/backend/tests/test_status.py b/backend/tests/test_status.py
index a13dba05..5e3ed693 100644
--- a/backend/tests/test_status.py
+++ b/backend/tests/test_status.py
@@ -138,7 +138,7 @@ def test_loaded_models_endpoint_includes_worker_snapshot(client):
"process": "worker",
"loaded_models": ["siglip"],
"in_flight": {},
- "failed_models": {"florence-2": {"error": "load failed", "failed_at": 0}},
+ "failed_models": {"captioner": {"error": "load failed", "failed_at": 0}},
"max_loaded_models": 5,
"updated_at": 0,
}
@@ -169,7 +169,7 @@ def test_loaded_models_endpoint_includes_worker_snapshot(client):
body = response.json()
assert "siglip" in body["loaded_models"]
assert (
- body["processes"]["worker"]["failed_models"]["florence-2"]["error"]
+ body["processes"]["worker"]["failed_models"]["captioner"]["error"]
== "load failed"
)
assert body["worker_health"]["state"] == "stale"
diff --git a/backend/uv.lock b/backend/uv.lock
index 51e212b5..e1441eb9 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -672,8 +672,8 @@ requires-dist = [
{ name = "torch", marker = "extra == 'nvidia'", specifier = "==2.8.0", index = "https://download.pytorch.org/whl/cu128", conflict = { package = "find-backend", extra = "nvidia" } },
{ name = "torchvision", marker = "extra == 'cpu'", specifier = "==0.23.0", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "find-backend", extra = "cpu" } },
{ name = "torchvision", marker = "extra == 'nvidia'", specifier = "==0.23.0", index = "https://download.pytorch.org/whl/cu128", conflict = { package = "find-backend", extra = "nvidia" } },
- { name = "transformers", marker = "extra == 'cpu'", specifier = "==4.49.0" },
- { name = "transformers", marker = "extra == 'nvidia'", specifier = "==4.49.0" },
+ { name = "transformers", marker = "extra == 'cpu'", specifier = "==5.3.0" },
+ { name = "transformers", marker = "extra == 'nvidia'", specifier = "==5.3.0" },
{ name = "ultralytics", marker = "extra == 'cpu'", specifier = ">=8.2.0" },
{ name = "ultralytics", marker = "extra == 'nvidia'", specifier = ">=8.2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.43.0,<0.44" },
@@ -833,21 +833,22 @@ wheels = [
[[package]]
name = "huggingface-hub"
-version = "0.36.2"
+version = "1.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "click" },
{ name = "filelock" },
{ name = "fsspec" },
- { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
+ { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
+ { name = "httpx" },
{ name = "packaging" },
{ name = "pyyaml" },
- { name = "requests" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/8f/999e4dda11c6187c78f090eac00895a47e11a0049308f07579bcb7aa3aa2/huggingface_hub-1.23.0.tar.gz", hash = "sha256:c04997fb8bbdace1e57b7703d30ed7678af51f70d00d241819ff411b92ae9a88", size = 919163, upload-time = "2026-07-09T14:49:32.315Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/ce/13b2ba57838b8db1e6bd033c1b21ce0b9f6153b87d4e4939f77074e41eb0/huggingface_hub-1.23.0-py3-none-any.whl", hash = "sha256:b1d604788f5adc7f0eb246e03e0ec19011ca06e38400218c347dccc3dffa64a2", size = 770336, upload-time = "2026-07-09T14:49:30.597Z" },
]
[[package]]
@@ -2231,24 +2232,26 @@ wheels = [
[[package]]
name = "safetensors"
-version = "0.7.0"
+version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
- { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
- { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
- { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
- { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
- { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
- { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
- { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
- { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
- { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
- { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
- { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
- { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
+ { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" },
+ { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" },
+ { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" },
+ { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" },
+ { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" },
+ { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" },
+ { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" },
+ { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" },
+ { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" },
]
[[package]]
@@ -2341,6 +2344,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" },
]
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
[[package]]
name = "six"
version = "1.17.0"
@@ -2459,27 +2471,28 @@ wheels = [
[[package]]
name = "tokenizers"
-version = "0.21.4"
+version = "0.22.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c2/2f/402986d0823f8d7ca139d969af2917fefaa9b947d1fb32f6168c509f2492/tokenizers-0.21.4.tar.gz", hash = "sha256:fa23f85fbc9a02ec5c6978da172cdcbac23498c3ca9f3645c5c68740ac007880", size = 351253, upload-time = "2025-07-28T15:48:54.325Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/98/c6/fdb6f72bf6454f52eb4a2510be7fb0f614e541a2554d6210e370d85efff4/tokenizers-0.21.4-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ccc10a7c3bcefe0f242867dc914fc1226ee44321eb618cfe3019b5df3400133", size = 2863987, upload-time = "2025-07-28T15:48:44.877Z" },
- { url = "https://files.pythonhosted.org/packages/8d/a6/28975479e35ddc751dc1ddc97b9b69bf7fcf074db31548aab37f8116674c/tokenizers-0.21.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:5e2f601a8e0cd5be5cc7506b20a79112370b9b3e9cb5f13f68ab11acd6ca7d60", size = 2732457, upload-time = "2025-07-28T15:48:43.265Z" },
- { url = "https://files.pythonhosted.org/packages/aa/8f/24f39d7b5c726b7b0be95dca04f344df278a3fe3a4deb15a975d194cbb32/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b376f5a1aee67b4d29032ee85511bbd1b99007ec735f7f35c8a2eb104eade5", size = 3012624, upload-time = "2025-07-28T13:22:43.895Z" },
- { url = "https://files.pythonhosted.org/packages/58/47/26358925717687a58cb74d7a508de96649544fad5778f0cd9827398dc499/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2107ad649e2cda4488d41dfd031469e9da3fcbfd6183e74e4958fa729ffbf9c6", size = 2939681, upload-time = "2025-07-28T13:22:47.499Z" },
- { url = "https://files.pythonhosted.org/packages/99/6f/cc300fea5db2ab5ddc2c8aea5757a27b89c84469899710c3aeddc1d39801/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c73012da95afafdf235ba80047699df4384fdc481527448a078ffd00e45a7d9", size = 3247445, upload-time = "2025-07-28T15:48:39.711Z" },
- { url = "https://files.pythonhosted.org/packages/be/bf/98cb4b9c3c4afd8be89cfa6423704337dc20b73eb4180397a6e0d456c334/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f23186c40395fc390d27f519679a58023f368a0aad234af145e0f39ad1212732", size = 3428014, upload-time = "2025-07-28T13:22:49.569Z" },
- { url = "https://files.pythonhosted.org/packages/75/c7/96c1cc780e6ca7f01a57c13235dd05b7bc1c0f3588512ebe9d1331b5f5ae/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc88bb34e23a54cc42713d6d98af5f1bf79c07653d24fe984d2d695ba2c922a2", size = 3193197, upload-time = "2025-07-28T13:22:51.471Z" },
- { url = "https://files.pythonhosted.org/packages/f2/90/273b6c7ec78af547694eddeea9e05de771278bd20476525ab930cecaf7d8/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51b7eabb104f46c1c50b486520555715457ae833d5aee9ff6ae853d1130506ff", size = 3115426, upload-time = "2025-07-28T15:48:41.439Z" },
- { url = "https://files.pythonhosted.org/packages/91/43/c640d5a07e95f1cf9d2c92501f20a25f179ac53a4f71e1489a3dcfcc67ee/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:714b05b2e1af1288bd1bc56ce496c4cebb64a20d158ee802887757791191e6e2", size = 9089127, upload-time = "2025-07-28T15:48:46.472Z" },
- { url = "https://files.pythonhosted.org/packages/44/a1/dd23edd6271d4dca788e5200a807b49ec3e6987815cd9d0a07ad9c96c7c2/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1340ff877ceedfa937544b7d79f5b7becf33a4cfb58f89b3b49927004ef66f78", size = 9055243, upload-time = "2025-07-28T15:48:48.539Z" },
- { url = "https://files.pythonhosted.org/packages/21/2b/b410d6e9021c4b7ddb57248304dc817c4d4970b73b6ee343674914701197/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3c1f4317576e465ac9ef0d165b247825a2a4078bcd01cba6b54b867bdf9fdd8b", size = 9298237, upload-time = "2025-07-28T15:48:50.443Z" },
- { url = "https://files.pythonhosted.org/packages/b7/0a/42348c995c67e2e6e5c89ffb9cfd68507cbaeb84ff39c49ee6e0a6dd0fd2/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c212aa4e45ec0bb5274b16b6f31dd3f1c41944025c2358faaa5782c754e84c24", size = 9461980, upload-time = "2025-07-28T15:48:52.325Z" },
- { url = "https://files.pythonhosted.org/packages/3d/d3/dacccd834404cd71b5c334882f3ba40331ad2120e69ded32cf5fda9a7436/tokenizers-0.21.4-cp39-abi3-win32.whl", hash = "sha256:6c42a930bc5f4c47f4ea775c91de47d27910881902b0f20e4990ebe045a415d0", size = 2329871, upload-time = "2025-07-28T15:48:56.841Z" },
- { url = "https://files.pythonhosted.org/packages/41/f2/fd673d979185f5dcbac4be7d09461cbb99751554ffb6718d0013af8604cb/tokenizers-0.21.4-cp39-abi3-win_amd64.whl", hash = "sha256:475d807a5c3eb72c59ad9b5fcdb254f6e17f53dfcbb9903233b0dfa9c943b597", size = 2507568, upload-time = "2025-07-28T15:48:55.456Z" },
+ { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
+ { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
+ { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
+ { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
+ { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
+ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
]
[[package]]
@@ -2647,23 +2660,22 @@ wheels = [
[[package]]
name = "transformers"
-version = "4.49.0"
+version = "5.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "filelock" },
{ name = "huggingface-hub" },
{ name = "numpy" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "regex" },
- { name = "requests" },
{ name = "safetensors" },
{ name = "tokenizers" },
{ name = "tqdm" },
+ { name = "typer" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/79/50/46573150944f46df8ec968eda854023165a84470b42f69f67c7d475dabc5/transformers-4.49.0.tar.gz", hash = "sha256:7e40e640b5b8dc3f48743f5f5adbdce3660c82baafbd3afdfc04143cdbd2089e", size = 8610952, upload-time = "2025-02-17T15:19:03.614Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/20/37/1f29af63e9c30156a3ed6ebc2754077016577c094f31de7b2631e5d379eb/transformers-4.49.0-py3-none-any.whl", hash = "sha256:6b4fded1c5fee04d384b1014495b4235a2b53c87503d7d592423c06128cbbe03", size = 9970275, upload-time = "2025-02-17T15:18:58.814Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" },
]
[[package]]
@@ -2677,6 +2689,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/66/b1eb52839f563623d185f0927eb3530ee4d5ffe9d377cdaf5346b306689e/triton-3.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c1d84a5c0ec2c0f8e8a072d7fd150cab84a9c239eaddc6706c081bfae4eb04", size = 155560068, upload-time = "2025-07-30T19:58:37.081Z" },
]
+[[package]]
+name = "typer"
+version = "0.26.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "rich" },
+ { name = "shellingham" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" },
+]
+
[[package]]
name = "typing-extensions"
version = "4.15.0"
diff --git a/docs/guides/hardware-acceleration.md b/docs/guides/hardware-acceleration.md
index 03e6605d..b58762bd 100644
--- a/docs/guides/hardware-acceleration.md
+++ b/docs/guides/hardware-acceleration.md
@@ -30,7 +30,7 @@ degrades to "not available" rather than raising:
- **ONNX Runtime execution providers** — CUDA, ROCm, CoreML (Apple), DirectML
(Windows). Used by the face-detection pipeline (InsightFace / ONNX).
- **PyTorch devices** — CUDA and MPS (Apple Metal). Used by the embedding
- (open_clip / SigLIP), captioning (Florence-2), and object-detection (YOLO)
+ (open_clip / SigLIP), captioning (BLIP), and object-detection (YOLO)
pipelines.
- **CPU** — always present as the floor.
diff --git a/docs/guides/real-ml-troubleshooting.md b/docs/guides/real-ml-troubleshooting.md
index 97cca72f..8d576426 100644
--- a/docs/guides/real-ml-troubleshooting.md
+++ b/docs/guides/real-ml-troubleshooting.md
@@ -90,7 +90,7 @@ Check:
## Common causes
-* Florence-2 model failed to load
+* BLIP caption model failed to load
* empty inference response
* unsupported/corrupted image
* model download interruption
From 125ba262fd2c4a253b32bc760f2583c7e5513bed Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Tue, 14 Jul 2026 13:57:09 +0530
Subject: [PATCH 09/21] fix: harden runtime and media paths
---
.github/workflows/publish.yml | 7 +++-
backend/src/find_api/core/model_manager.py | 3 ++
backend/src/find_api/core/sqlite_queue.py | 8 ++--
backend/src/find_api/routers/auth.py | 6 ++-
backend/src/find_api/workers/rq_worker.py | 39 +++++++++++++++++++
backend/tests/test_rq_worker.py | 35 +++++++++++++++++
compose.base.yml | 6 +--
frontend/biome.json | 3 ++
frontend/src/app/gallery/page.tsx | 33 +++++++++++++---
frontend/src/components/justified-grid.tsx | 19 ++++++---
.../src/components/timeline-media-view.tsx | 9 +++--
11 files changed, 143 insertions(+), 25 deletions(-)
create mode 100644 backend/src/find_api/workers/rq_worker.py
create mode 100644 backend/tests/test_rq_worker.py
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 197ed70a..a33b75fe 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -23,7 +23,6 @@ on:
permissions:
contents: read
- packages: write
concurrency:
group: publish-${{ github.ref }}-${{ inputs.image_tag || github.ref_name }}
@@ -70,6 +69,9 @@ jobs:
name: Publish backend (${{ matrix.profile }})
needs: metadata
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
strategy:
fail-fast: false
matrix:
@@ -120,6 +122,9 @@ jobs:
name: Publish web image
needs: metadata
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
steps:
- name: Check out source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
diff --git a/backend/src/find_api/core/model_manager.py b/backend/src/find_api/core/model_manager.py
index 6c49f002..4874b608 100644
--- a/backend/src/find_api/core/model_manager.py
+++ b/backend/src/find_api/core/model_manager.py
@@ -121,6 +121,9 @@ def cleanup_loop():
try:
time.sleep(interval_seconds)
self.unload_idle_models(ttl_seconds)
+ # Keep the process heartbeat fresh even when no models
+ # are loaded and the worker is otherwise idle.
+ self.publish_status()
except Exception as e:
logger.error(f"Error in model cleanup thread: {e}")
diff --git a/backend/src/find_api/core/sqlite_queue.py b/backend/src/find_api/core/sqlite_queue.py
index 6052ba4c..e976e38f 100644
--- a/backend/src/find_api/core/sqlite_queue.py
+++ b/backend/src/find_api/core/sqlite_queue.py
@@ -39,7 +39,7 @@
WHERE id = (
SELECT id FROM job_queue
WHERE status = 'queued'
- ORDER BY created_at ASC
+ ORDER BY created_at ASC, rowid ASC
LIMIT 1
)
RETURNING id, type, payload, created_at
@@ -67,14 +67,14 @@
SELECT id, type, status, payload, error_info, created_at, started_at, completed_at
FROM job_queue
WHERE status IN ('queued', 'running')
-ORDER BY created_at ASC
+ORDER BY created_at ASC, rowid ASC
"""
SQL_LIST_FAILED = """
SELECT id, type, status, payload, error_info, created_at, started_at, completed_at
FROM job_queue
WHERE status = 'failed'
-ORDER BY completed_at DESC
+ORDER BY completed_at DESC, rowid DESC
"""
SQL_COUNT_STATUS = """
@@ -88,7 +88,7 @@
SQL_RESET_STALE = """
UPDATE job_queue
SET status = 'queued', started_at = NULL, error_info = ?
-WHERE status = 'running' AND started_at < ?
+WHERE status = 'running' AND started_at <= ?
"""
SQL_CREATE_CLUSTERING_LOCKS = """
diff --git a/backend/src/find_api/routers/auth.py b/backend/src/find_api/routers/auth.py
index 60f7f8b8..877e3e8b 100644
--- a/backend/src/find_api/routers/auth.py
+++ b/backend/src/find_api/routers/auth.py
@@ -294,7 +294,11 @@ def update_profile(
if body.display_name is not None:
user.display_name = body.display_name.strip() or None
- db.commit()
+ try:
+ db.commit()
+ except IntegrityError as exc:
+ db.rollback()
+ raise HTTPException(409, "Username is already in use") from exc
db.refresh(user)
return {"user": _user_dict(user)}
diff --git a/backend/src/find_api/workers/rq_worker.py b/backend/src/find_api/workers/rq_worker.py
new file mode 100644
index 00000000..0c990be0
--- /dev/null
+++ b/backend/src/find_api/workers/rq_worker.py
@@ -0,0 +1,39 @@
+"""RQ worker integration for idle health and applied-runtime reporting."""
+
+import logging
+from typing import Any
+
+from rq.worker.worker_classes import SimpleWorker
+
+from find_api.core.config import settings
+from find_api.core.database import SessionLocal
+from find_api.core.model_manager import get_model_manager
+from find_api.core.runtime_profile import load_runtime_preferences, resolve_runtime
+
+logger = logging.getLogger(__name__)
+
+
+def initialize_worker_observability() -> None:
+ """Publish worker health before the first queued job is received."""
+ try:
+ manager = get_model_manager()
+ manager.start_autocleanup(
+ ttl_seconds=settings.ML_MODEL_IDLE_TTL_SECONDS,
+ process_name="worker",
+ )
+ with SessionLocal() as db:
+ runtime = resolve_runtime(load_runtime_preferences(db))
+ manager.set_runtime_status(runtime.to_worker_status(source="database"))
+ except Exception: # noqa: BLE001
+ # Queue availability is more important than observability during a
+ # transient database/Redis startup race. The first job retries this
+ # runtime publication through ``_begin_worker_runtime``.
+ logger.exception("Failed to initialize worker runtime observability")
+
+
+class FindWorker(SimpleWorker):
+ """SimpleWorker that reports healthy state while waiting for work."""
+
+ def work(self, *args: Any, **kwargs: Any) -> bool:
+ initialize_worker_observability()
+ return super().work(*args, **kwargs)
diff --git a/backend/tests/test_rq_worker.py b/backend/tests/test_rq_worker.py
new file mode 100644
index 00000000..1869afa6
--- /dev/null
+++ b/backend/tests/test_rq_worker.py
@@ -0,0 +1,35 @@
+"""Tests for RQ worker startup observability."""
+
+from unittest.mock import MagicMock, Mock, patch
+
+from find_api.workers.rq_worker import initialize_worker_observability
+
+
+def test_worker_publishes_runtime_before_first_job():
+ manager = Mock()
+ session = Mock()
+ session_factory = MagicMock()
+ session_factory.return_value.__enter__.return_value = session
+ preferences = Mock()
+ runtime = Mock()
+ runtime.to_worker_status.return_value = {"applied_mode": "full"}
+
+ with (
+ patch(
+ "find_api.workers.rq_worker.get_model_manager",
+ return_value=manager,
+ ),
+ patch("find_api.workers.rq_worker.SessionLocal", session_factory),
+ patch(
+ "find_api.workers.rq_worker.load_runtime_preferences",
+ return_value=preferences,
+ ),
+ patch(
+ "find_api.workers.rq_worker.resolve_runtime",
+ return_value=runtime,
+ ),
+ ):
+ initialize_worker_observability()
+
+ manager.start_autocleanup.assert_called_once()
+ manager.set_runtime_status.assert_called_once_with({"applied_mode": "full"})
diff --git a/compose.base.yml b/compose.base.yml
index 136ad5ec..ddacdb49 100644
--- a/compose.base.yml
+++ b/compose.base.yml
@@ -59,7 +59,7 @@ services:
UV_SYNC_EXTRAS: ""
command: uvicorn find_api.main:app --host 0.0.0.0 --port 8000 --log-level warning
ports:
- - "8000:8000"
+ - "127.0.0.1:8000:8000"
environment: &backend-environment
DATABASE_URL: ${DATABASE_URL}
MINIO_ENDPOINT: ${MINIO_ENDPOINT:-minio:9000}
@@ -102,7 +102,7 @@ services:
PYTHON_VERSION: "3.12"
FIND_BUILD_PROFILE: no-ai
UV_SYNC_EXTRAS: ""
- command: rq worker --worker-class ${RQ_WORKER_CLASS:-rq.worker.worker_classes.SimpleWorker} --url ${REDIS_URL:-redis://:find-redis-dev@redis:6379} high default low
+ command: rq worker --worker-class find_api.workers.rq_worker.FindWorker --url ${REDIS_URL} high default low
environment:
<<: *backend-environment
MODEL_MANAGER_PROCESS_NAME: worker
@@ -123,7 +123,7 @@ services:
dockerfile: Dockerfile
target: production
ports:
- - "3000:3000"
+ - "127.0.0.1:3000:3000"
environment:
NEXT_PUBLIC_API_URL: http://localhost:8000
NEXT_PUBLIC_MINIO_BUCKET: ${NEXT_PUBLIC_MINIO_BUCKET:-images}
diff --git a/frontend/biome.json b/frontend/biome.json
index ea453fdf..e9604f23 100644
--- a/frontend/biome.json
+++ b/frontend/biome.json
@@ -21,6 +21,9 @@
"clientKind": "git",
"useIgnoreFile": true
},
+ "files": {
+ "includes": ["**", "!!src-tauri/gen", "!!src-tauri/target"]
+ },
"formatter": {
"enabled": true,
"indentStyle": "space",
diff --git a/frontend/src/app/gallery/page.tsx b/frontend/src/app/gallery/page.tsx
index 61f983e1..8d64cce4 100644
--- a/frontend/src/app/gallery/page.tsx
+++ b/frontend/src/app/gallery/page.tsx
@@ -839,6 +839,24 @@ function GalleryPageContent() {
},
});
+ const downloadMutation = useMutation({
+ mutationFn: async (item: { id: number; filename: string }) => {
+ const response = await api.get(`/api/image/${item.id}/original`, {
+ responseType: "blob",
+ });
+ const objectUrl = URL.createObjectURL(response.data);
+ try {
+ const anchor = document.createElement("a");
+ anchor.href = objectUrl;
+ anchor.download = item.filename;
+ anchor.click();
+ } finally {
+ URL.revokeObjectURL(objectUrl);
+ }
+ },
+ onError: () => toast.error("Download failed. Please try again."),
+ });
+
const moveToVaultMutation = useMutation({
mutationFn: async (mediaId: number) => {
if (!vaultSessionToken) {
@@ -1313,17 +1331,20 @@ function GalleryPageContent() {
}`}
/>
-
+ downloadMutation.mutate({
+ id: item.id,
+ filename: item.filename,
+ })
}
- download={item.filename}
+ disabled={downloadMutation.isPending}
className="icon-button h-8 w-8"
aria-label="Download image"
>
-
+
{(item.status === "failed" ||
(item.status === "indexed" && !item.caption)) && (
{
className?: string;
getKey: (item: T, index: number) => string | number;
renderItem: (item: T, index: number, box: JustifiedBox) => ReactNode;
+ scrollContainerRef?: RefObject;
}
const DEFAULT_TARGET_ROW_HEIGHT = 235;
@@ -56,6 +58,7 @@ export function JustifiedGrid({
className,
getKey,
renderItem,
+ scrollContainerRef,
}: JustifiedGridProps) {
const containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
@@ -89,24 +92,28 @@ export function JustifiedGrid({
if (typeof window === "undefined") {
return;
}
+ const scrollContainer = scrollContainerRef?.current;
const update = () => {
const element = containerRef.current;
if (!element) {
return;
}
const rect = element.getBoundingClientRect();
- // How far the grid's top has scrolled above the viewport top.
- setScrollTop(Math.max(0, -rect.top));
- setViewportHeight(window.innerHeight || 0);
+ const viewportTop = scrollContainer?.getBoundingClientRect().top ?? 0;
+ setScrollTop(Math.max(0, viewportTop - rect.top));
+ setViewportHeight(
+ scrollContainer?.clientHeight ?? window.innerHeight ?? 0,
+ );
};
update();
- window.addEventListener("scroll", update, { passive: true });
+ const scrollTarget = scrollContainer ?? window;
+ scrollTarget.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
- window.removeEventListener("scroll", update);
+ scrollTarget.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
- }, []);
+ }, [scrollContainerRef]);
const layout = useMemo(
() =>
diff --git a/frontend/src/components/timeline-media-view.tsx b/frontend/src/components/timeline-media-view.tsx
index a57d98d5..fe244569 100644
--- a/frontend/src/components/timeline-media-view.tsx
+++ b/frontend/src/components/timeline-media-view.tsx
@@ -93,7 +93,7 @@ export function TimelineMediaView({
getHeight = () => null,
getThumbnailUrl,
getOriginalUrl,
- getAlt = () => "",
+ getAlt,
getOpenLabel,
getItemTestId,
getOpenTestId,
@@ -145,7 +145,7 @@ export function TimelineMediaView({
return {
id,
thumbnailUrl,
- alt: getAlt(item),
+ alt: getAlt?.(item),
// View-only shares deliberately fall back to their share-scoped
// thumbnail; a private route is never synthesized here.
originalUrl: resolveApiRoute(rawOriginalUrl) ?? thumbnailUrl,
@@ -304,6 +304,7 @@ export function TimelineMediaView({
({
item,
ratio: mediaAspectRatio(getWidth(item), getHeight(item)),
@@ -325,7 +326,7 @@ export function TimelineMediaView({
data-testid={getOpenTestId?.(item)}
aria-label={
getOpenLabel?.(item) ??
- `Open ${getAlt(item) || "image"}`
+ `Open ${getAlt?.(item) || "image"}`
}
onClick={() => {
if (onOpenItem) onOpenItem(item, timelineIndex);
@@ -337,7 +338,7 @@ export function TimelineMediaView({
// biome-ignore lint/performance/noImgElement: authenticated and share-scoped media URLs are not Next image assets.
From 215ccc0a275ea75934ed6713626a5e3f9b443b6c Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Tue, 14 Jul 2026 13:57:22 +0530
Subject: [PATCH 10/21] feat: add recoverable private vault controls
---
...0714_vault_credentials_and_storage_mode.py | 30 +++
backend/src/find_api/core/database.py | 14 +
backend/src/find_api/models/vault.py | 2 +
backend/src/find_api/routers/vault.py | 244 +++++++++++++++++-
backend/tests/test_vault.py | 96 ++++++-
docs/plans/partial/vault-encryption-design.md | 2 +-
frontend/src/__tests__/vault-gallery.test.tsx | 13 +-
.../src/components/vault/VaultGallery.tsx | 221 +++++++++++++++-
frontend/src/components/vault/VaultUnlock.tsx | 232 ++++++++++++-----
frontend/src/components/vault/vault-client.ts | 49 ++++
10 files changed, 826 insertions(+), 77 deletions(-)
create mode 100644 backend/alembic/versions/20260714_vault_credentials_and_storage_mode.py
diff --git a/backend/alembic/versions/20260714_vault_credentials_and_storage_mode.py b/backend/alembic/versions/20260714_vault_credentials_and_storage_mode.py
new file mode 100644
index 00000000..79efd0d7
--- /dev/null
+++ b/backend/alembic/versions/20260714_vault_credentials_and_storage_mode.py
@@ -0,0 +1,30 @@
+"""Add recoverable password-gated vault storage mode.
+
+Revision ID: 20260714_vault_credentials
+Revises: 20260712_media_gps
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "20260714_vault_credentials"
+down_revision = "20260712mediagps"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "vault_config", sa.Column("recovery_code_hash", sa.String(255), nullable=True)
+ )
+ op.add_column(
+ "vault_config",
+ sa.Column(
+ "storage_mode", sa.String(32), nullable=False, server_default="protected"
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("vault_config", "storage_mode")
+ op.drop_column("vault_config", "recovery_code_hash")
diff --git a/backend/src/find_api/core/database.py b/backend/src/find_api/core/database.py
index 4da41320..e2503cf8 100644
--- a/backend/src/find_api/core/database.py
+++ b/backend/src/find_api/core/database.py
@@ -277,10 +277,24 @@ def init_db():
"salt BYTEA NOT NULL, "
"verifier_nonce BYTEA NOT NULL, "
"verifier_ciphertext BYTEA NOT NULL, "
+ "recovery_code_hash VARCHAR(255), "
+ "storage_mode VARCHAR(32) NOT NULL DEFAULT 'protected', "
"created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()"
")"
)
)
+ conn.execute(
+ text(
+ "ALTER TABLE IF EXISTS vault_config "
+ "ADD COLUMN IF NOT EXISTS recovery_code_hash VARCHAR(255)"
+ )
+ )
+ conn.execute(
+ text(
+ "ALTER TABLE IF EXISTS vault_config "
+ "ADD COLUMN IF NOT EXISTS storage_mode VARCHAR(32) NOT NULL DEFAULT 'protected'"
+ )
+ )
conn.execute(
text(
"CREATE TABLE IF NOT EXISTS vault_metadata ("
diff --git a/backend/src/find_api/models/vault.py b/backend/src/find_api/models/vault.py
index 75ab592c..a4d79362 100644
--- a/backend/src/find_api/models/vault.py
+++ b/backend/src/find_api/models/vault.py
@@ -24,6 +24,8 @@ class VaultConfig(Base):
salt = Column(LargeBinary, nullable=False)
verifier_nonce = Column(LargeBinary, nullable=False)
verifier_ciphertext = Column(LargeBinary, nullable=False)
+ recovery_code_hash = Column(String(255), nullable=True)
+ storage_mode = Column(String(32), nullable=False, server_default="protected")
created_at = Column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
diff --git a/backend/src/find_api/routers/vault.py b/backend/src/find_api/routers/vault.py
index 88f1913e..5a43a456 100644
--- a/backend/src/find_api/routers/vault.py
+++ b/backend/src/find_api/routers/vault.py
@@ -1,4 +1,4 @@
-"""Vault endpoints for unlocking, hiding, and streaming encrypted images."""
+"""Password-gated vault endpoints with legacy encrypted-item migration."""
from __future__ import annotations
@@ -34,10 +34,12 @@
verify_master_key,
)
from find_api.core.database import get_db
+from find_api.core.auth import hash_password, verify_password
from find_api.core.dependencies import get_required_user
from find_api.core.storage import (
delete_file,
download_file_to_path,
+ get_file,
upload_file,
upload_thumbnail,
)
@@ -61,6 +63,20 @@ class VaultUnlockRequest(BaseModel):
passphrase: str
+class VaultSetupRequest(BaseModel):
+ passphrase: str
+
+
+class VaultPasswordChangeRequest(BaseModel):
+ current_passphrase: str
+ new_passphrase: str
+
+
+class VaultRecoverRequest(BaseModel):
+ recovery_code: str
+ new_passphrase: str
+
+
class VaultLockRequest(BaseModel):
session_token: Optional[str] = None
@@ -195,6 +211,90 @@ def _load_or_create_master_key(db: Session, passphrase: str) -> bytes:
return master_key
+def _protected_storage_enabled(db: Session) -> bool:
+ """Return true for migrated schemas; old test/legacy schemas remain compatible."""
+ try:
+ row = db.execute(
+ text("SELECT storage_mode FROM vault_config WHERE id = 1")
+ ).first()
+ return bool(row and row[0] == "protected")
+ except Exception: # noqa: BLE001
+ db.rollback()
+ return False
+
+
+def _replace_vault_credentials(
+ db: Session, passphrase: str, recovery_hash: str
+) -> bytes:
+ salt = os.urandom(16)
+ master_key = derive_master_key(passphrase, salt)
+ nonce, ciphertext = create_key_verifier(master_key)
+ db.execute(
+ text(
+ "UPDATE vault_config SET salt = :salt, verifier_nonce = :nonce, "
+ "verifier_ciphertext = :ciphertext, recovery_code_hash = :recovery_hash, "
+ "storage_mode = 'protected' WHERE id = 1"
+ ),
+ {
+ "salt": salt,
+ "nonce": nonce,
+ "ciphertext": ciphertext,
+ "recovery_hash": recovery_hash,
+ },
+ )
+ db.commit()
+ return master_key
+
+
+def _migrate_legacy_encrypted_items(db: Session, master_key: bytes) -> None:
+ """Move legacy encrypted blobs back into private object storage after unlock."""
+ if not _protected_storage_enabled(db):
+ return
+ rows = db.execute(
+ text("SELECT media_id, encrypted_path, iv FROM vault_metadata")
+ ).all()
+ for media_id, encrypted_path, raw_iv in rows:
+ media = _load_media_or_404(db, int(media_id))
+ encrypted_file = Path(encrypted_path)
+ if not encrypted_file.exists():
+ logger.error("Legacy vault blob missing for media %s", media_id)
+ continue
+ plaintext_file = _decrypt_to_temporary_path(
+ master_key,
+ _normalize_binary(raw_iv),
+ encrypted_file,
+ associated_data=build_vault_aad(media.id, media.file_hash),
+ prefix=f"vault-migrate-{media.id}-",
+ )
+ uploaded_thumbnail: Optional[dict] = None
+ try:
+ plaintext = plaintext_file.read_bytes()
+ upload_file(
+ plaintext,
+ media.minio_key,
+ media.content_type or "application/octet-stream",
+ )
+ uploaded_thumbnail = upload_thumbnail(plaintext, media.file_hash)
+ db.execute(
+ text("DELETE FROM vault_metadata WHERE media_id = :media_id"),
+ {"media_id": media.id},
+ )
+ media.vault_state = "hidden"
+ media.encrypted_at = None
+ _apply_thumbnail_metadata(media, uploaded_thumbnail)
+ db.commit()
+ encrypted_file.unlink(missing_ok=True)
+ except Exception:
+ db.rollback()
+ _delete_storage_objects_best_effort(
+ media.minio_key,
+ uploaded_thumbnail.get("thumbnail_key") if uploaded_thumbnail else None,
+ )
+ raise
+ finally:
+ plaintext_file.unlink(missing_ok=True)
+
+
def _load_media_or_404(db: Session, media_id: int) -> Media:
"""Return a media row or raise the public image-not-found response."""
media = db.query(Media).filter(Media.id == media_id).first()
@@ -356,11 +456,107 @@ def unlock_vault(
)
master_key = _load_or_create_master_key(db, payload.passphrase)
+ _migrate_legacy_encrypted_items(db, master_key)
session_token = secrets.token_urlsafe(32)
set_session_key(session_token, master_key)
return {"session_token": session_token}
+@router.get("/vault/status")
+def vault_status(db: Session = Depends(get_db)):
+ """Report setup state without exposing verifier or recovery material."""
+ initialized = _load_vault_config(db) is not None
+ recovery_available = False
+ if initialized:
+ try:
+ row = db.execute(
+ text("SELECT recovery_code_hash FROM vault_config WHERE id = 1")
+ ).first()
+ recovery_available = bool(row and row[0])
+ except Exception: # noqa: BLE001
+ db.rollback()
+ return {"initialized": initialized, "recovery_available": recovery_available}
+
+
+@router.post("/vault/setup")
+@limiter.limit("5/minute")
+def setup_vault(
+ request: Request, payload: VaultSetupRequest, db: Session = Depends(get_db)
+):
+ """Create a new vault password and return a one-time local recovery code."""
+ if _load_vault_config(db) is not None:
+ raise HTTPException(409, "Vault is already configured")
+ if len(payload.passphrase) < MIN_VAULT_PASSPHRASE_LENGTH:
+ raise HTTPException(
+ 400,
+ f"Vault password must be at least {MIN_VAULT_PASSPHRASE_LENGTH} characters",
+ )
+ _create_vault_config(db, payload.passphrase)
+ recovery_code = "-".join(secrets.token_hex(4).upper() for _ in range(4))
+ master_key = _replace_vault_credentials(
+ db, payload.passphrase, hash_password(recovery_code)
+ )
+ token = secrets.token_urlsafe(32)
+ set_session_key(token, master_key)
+ return {"session_token": token, "recovery_code": recovery_code}
+
+
+@router.post("/vault/password")
+@limiter.limit("5/minute")
+def change_vault_password(
+ request: Request, payload: VaultPasswordChangeRequest, db: Session = Depends(get_db)
+):
+ """Change the vault password and rotate its local recovery code."""
+ if len(payload.new_passphrase) < MIN_VAULT_PASSPHRASE_LENGTH:
+ raise HTTPException(
+ 400,
+ f"Vault password must be at least {MIN_VAULT_PASSPHRASE_LENGTH} characters",
+ )
+ old_key = _load_or_create_master_key(db, payload.current_passphrase)
+ _migrate_legacy_encrypted_items(db, old_key)
+ recovery_code = "-".join(secrets.token_hex(4).upper() for _ in range(4))
+ master_key = _replace_vault_credentials(
+ db, payload.new_passphrase, hash_password(recovery_code)
+ )
+ token = secrets.token_urlsafe(32)
+ set_session_key(token, master_key)
+ return {"session_token": token, "recovery_code": recovery_code}
+
+
+@router.post("/vault/recover")
+@limiter.limit("5/minute")
+def recover_vault(
+ request: Request, payload: VaultRecoverRequest, db: Session = Depends(get_db)
+):
+ """Reset a protected-storage vault password with its one-time recovery code."""
+ if len(payload.new_passphrase) < MIN_VAULT_PASSPHRASE_LENGTH:
+ raise HTTPException(
+ 400,
+ f"Vault password must be at least {MIN_VAULT_PASSPHRASE_LENGTH} characters",
+ )
+ row = db.execute(
+ text("SELECT recovery_code_hash FROM vault_config WHERE id = 1")
+ ).first()
+ if (
+ not row
+ or not row[0]
+ or not verify_password(payload.recovery_code.strip().upper(), row[0])
+ ):
+ raise HTTPException(401, "Invalid recovery code")
+ if db.execute(text("SELECT 1 FROM vault_metadata LIMIT 1")).first():
+ raise HTTPException(
+ 409,
+ "Unlock once with the existing password before recovery so legacy encrypted items can be migrated",
+ )
+ recovery_code = "-".join(secrets.token_hex(4).upper() for _ in range(4))
+ master_key = _replace_vault_credentials(
+ db, payload.new_passphrase, hash_password(recovery_code)
+ )
+ token = secrets.token_urlsafe(32)
+ set_session_key(token, master_key)
+ return {"session_token": token, "recovery_code": recovery_code}
+
+
@router.get("/vault/list")
def list_vault_media(
authorization: Optional[str] = Header(default=None),
@@ -423,6 +619,14 @@ def hide_media(
if existing_metadata is not None:
raise HTTPException(status_code=409, detail="Vault metadata already exists")
+ if _protected_storage_enabled(db):
+ media.is_hidden = True
+ media.vault_state = "hidden"
+ media.hidden_at = datetime.now(timezone.utc)
+ media.encrypted_at = None
+ db.commit()
+ return {"status": "hidden", "media_id": media.id, "storage_mode": "protected"}
+
original_key = media.minio_key
thumbnail_key = media.thumbnail_key
content_type = media.content_type or "application/octet-stream"
@@ -520,6 +724,17 @@ def restore_media(
metadata = _load_vault_metadata(db, media.id)
if metadata is None:
+ if _protected_storage_enabled(db):
+ media.is_hidden = False
+ media.vault_state = "visible"
+ media.hidden_at = None
+ media.encrypted_at = None
+ db.commit()
+ return {
+ "status": "restored",
+ "media_id": media.id,
+ "encrypted_blob_removed": False,
+ }
raise HTTPException(status_code=404, detail="Vault metadata not found")
encrypted_path, iv = metadata
@@ -627,6 +842,21 @@ def thumbnail_hidden_media(
metadata = _load_vault_metadata(db, media_id)
if metadata is None:
+ if _protected_storage_enabled(db):
+ object_key = media.thumbnail_key or media.minio_key
+ try:
+ content = get_file(object_key)
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(
+ status_code=404, detail="Vault preview not found"
+ ) from exc
+ return Response(
+ content=content,
+ media_type=media.thumbnail_content_type
+ or media.content_type
+ or "application/octet-stream",
+ headers={"Cache-Control": "private, no-store", "Pragma": "no-cache"},
+ )
raise HTTPException(status_code=404, detail="Vault metadata not found")
encrypted_path, iv = metadata
@@ -672,6 +902,18 @@ def stream_hidden_media(
raise HTTPException(status_code=404, detail="Image not found")
metadata = _load_vault_metadata(db, media_id)
if metadata is None:
+ if _protected_storage_enabled(db):
+ try:
+ content = get_file(media.minio_key)
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(
+ status_code=404, detail="Vault image not found"
+ ) from exc
+ return Response(
+ content=content,
+ media_type=media.content_type or "application/octet-stream",
+ headers={"Cache-Control": "private, no-store", "Pragma": "no-cache"},
+ )
raise HTTPException(status_code=404, detail="Vault metadata not found")
encrypted_path, iv = metadata
diff --git a/backend/tests/test_vault.py b/backend/tests/test_vault.py
index 96881181..b041a83a 100644
--- a/backend/tests/test_vault.py
+++ b/backend/tests/test_vault.py
@@ -112,7 +112,9 @@ def unlock_vault(
def hide_media(client, db, *, media: Media, token: str) -> Path:
- """Hide a seeded media row using the vault endpoint."""
+ """Hide through the legacy encrypted path to retain migration coverage."""
+ db.execute(text("UPDATE vault_config SET storage_mode = 'encrypted' WHERE id = 1"))
+ db.commit()
with (
patch(
"find_api.routers.vault.download_file_to_path",
@@ -176,6 +178,70 @@ def test_unlock_wrong_passphrase_rejected_after_vault_initialized(self, client,
assert response.status_code == 401
+class TestVaultCredentials:
+ """Setup and recovery rotate credentials without exposing stored secrets."""
+
+ def test_setup_status_and_recovery_round_trip(self, client, db):
+ app.state.limiter.reset()
+ vault_router.limiter.reset()
+ prepare_vault_tables(db)
+
+ status = client.get("/api/vault/status")
+ assert status.status_code == 200
+ assert status.json() == {
+ "initialized": False,
+ "recovery_available": False,
+ }
+
+ setup = client.post(
+ "/api/vault/setup",
+ json={"passphrase": "initial local password"},
+ )
+ assert setup.status_code == 200
+ recovery_code = setup.json()["recovery_code"]
+ assert recovery_code
+ assert setup.json()["session_token"]
+
+ stored_hash = db.execute(
+ text("SELECT recovery_code_hash FROM vault_config WHERE id = 1")
+ ).scalar_one()
+ assert stored_hash
+ assert recovery_code not in stored_hash
+
+ status = client.get("/api/vault/status")
+ assert status.json() == {
+ "initialized": True,
+ "recovery_available": True,
+ }
+
+ recovered = client.post(
+ "/api/vault/recover",
+ json={
+ "recovery_code": recovery_code.lower(),
+ "new_passphrase": "replacement local password",
+ },
+ )
+ assert recovered.status_code == 200
+ assert recovered.json()["session_token"]
+ assert recovered.json()["recovery_code"] != recovery_code
+
+ app.state.limiter.reset()
+ vault_router.limiter.reset()
+ old_unlock = client.post(
+ "/api/vault/unlock",
+ json={"passphrase": "initial local password"},
+ )
+ assert old_unlock.status_code == 401
+
+ app.state.limiter.reset()
+ vault_router.limiter.reset()
+ new_unlock = client.post(
+ "/api/vault/unlock",
+ json={"passphrase": "replacement local password"},
+ )
+ assert new_unlock.status_code == 200
+
+
class TestVaultHide:
"""Vault hide endpoint behavior."""
@@ -243,6 +309,10 @@ def test_hide_removes_plaintext_original_and_thumbnail(
):
media = seed_media(db, filename="with-thumb.png", with_thumbnail=True)
token = unlock_vault(client, db)
+ db.execute(
+ text("UPDATE vault_config SET storage_mode = 'encrypted' WHERE id = 1")
+ )
+ db.commit()
delete_mock = Mock()
with (
@@ -276,6 +346,30 @@ def test_hide_removes_plaintext_original_and_thumbnail(
call(media.thumbnail_key),
]
+ def test_protected_storage_hide_keeps_private_objects(self, client, db):
+ media = seed_media(db, filename="protected.png", with_thumbnail=True)
+ token = unlock_vault(client, db)
+ delete_mock = Mock()
+ with patch("find_api.routers.vault.delete_file", delete_mock):
+ response = client.post(
+ "/api/vault/hide",
+ json={"media_id": media.id},
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ assert response.status_code == 200
+ assert response.json()["storage_mode"] == "protected"
+ db.refresh(media)
+ assert media.is_hidden is True
+ assert media.vault_state == "hidden"
+ assert (
+ db.execute(
+ text("SELECT 1 FROM vault_metadata WHERE media_id = :media_id"),
+ {"media_id": media.id},
+ ).first()
+ is None
+ )
+ delete_mock.assert_not_called()
+
class TestVaultLock:
"""Locking invalidates the in-memory key immediately."""
diff --git a/docs/plans/partial/vault-encryption-design.md b/docs/plans/partial/vault-encryption-design.md
index b966f261..71fdd8b1 100644
--- a/docs/plans/partial/vault-encryption-design.md
+++ b/docs/plans/partial/vault-encryption-design.md
@@ -4,7 +4,7 @@
- **Date:** 2026-05-21
- **Last reviewed:** 2026-05-29
- **Related:** Issue #183
-- **Current implementation status:** Vault unlock, hide, list, lock, and stream flows exist with AES-256-GCM encrypted blob storage, metadata-bound AEAD associated data, and tests. Hidden media is filtered from gallery, search, clusters, and people endpoints, and workers skip ML processing for hidden rows.
+- **Current implementation status:** This is now a legacy-format migration note. Find 1.1.1 keeps hidden media in its existing private object store behind password-gated, memory-only vault sessions; it no longer encrypts newly hidden image bytes. Existing AES-256-GCM vault blobs are decrypted back into private storage after the first successful unlock, with the legacy reader retained until migration succeeds. Hidden media remains filtered from gallery, search, clusters, people, and ordinary media endpoints.
## Problem
diff --git a/frontend/src/__tests__/vault-gallery.test.tsx b/frontend/src/__tests__/vault-gallery.test.tsx
index 24e4aede..858643fe 100644
--- a/frontend/src/__tests__/vault-gallery.test.tsx
+++ b/frontend/src/__tests__/vault-gallery.test.tsx
@@ -16,6 +16,11 @@ const vaultClient = vi.hoisted(() => ({
listVaultItems: vi.fn(),
lockVaultSession: vi.fn(),
restoreVaultItem: vi.fn(),
+ changeVaultPassword: vi.fn(),
+ getVaultStatus: vi.fn(),
+ setupVault: vi.fn(),
+ recoverVault: vi.fn(),
+ unlockVault: vi.fn(),
}));
vi.mock("@/components/vault/vault-client", () => ({
@@ -87,6 +92,10 @@ beforeEach(() => {
vaultStore.getState().unlock("vault-session-token");
vaultClient.listVaultItems.mockResolvedValue([item]);
+ vaultClient.getVaultStatus.mockResolvedValue({
+ initialized: true,
+ recovery_available: true,
+ });
vaultClient.fetchVaultThumbnail.mockResolvedValue(
new Blob(["thumbnail"], { type: "image/webp" }),
);
@@ -156,6 +165,8 @@ describe("VaultGallery", () => {
"vault-session-token",
);
});
- expect(screen.getByRole("button", { name: "Unlock Vault" })).toBeVisible();
+ expect(
+ await screen.findByRole("button", { name: /unlock vault/i }),
+ ).toBeVisible();
});
});
diff --git a/frontend/src/components/vault/VaultGallery.tsx b/frontend/src/components/vault/VaultGallery.tsx
index 4a9a9e16..d00967ae 100644
--- a/frontend/src/components/vault/VaultGallery.tsx
+++ b/frontend/src/components/vault/VaultGallery.tsx
@@ -1,7 +1,14 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ImageOff, Loader2, Lock, RotateCcw } from "lucide-react";
+import {
+ ImageOff,
+ KeyRound,
+ Loader2,
+ Lock,
+ RotateCcw,
+ Settings2,
+} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AssetViewer } from "@/components/asset-viewer";
import {
@@ -11,6 +18,7 @@ import {
import { vaultStore } from "@/store/vaultStore";
import { VaultUnlock } from "./VaultUnlock";
import {
+ changeVaultPassword,
fetchVaultOriginal,
fetchVaultThumbnail,
isExpiredVaultSession,
@@ -118,6 +126,27 @@ export function VaultGallery() {
const isUnlocked = vaultStore((state) => state.isUnlocked);
const sessionToken = vaultStore((state) => state.sessionToken);
const [sessionMessage, setSessionMessage] = useState(null);
+ const [pendingRestoreIds, setPendingRestoreIds] = useState>(
+ () => new Set(),
+ );
+ const [lockMode, setLockMode] = useState<"immediate" | "delay" | "idle">(
+ () => {
+ try {
+ const saved = localStorage.getItem("find-vault-lock-mode");
+ return saved === "delay" || saved === "idle" ? saved : "immediate";
+ } catch {
+ return "immediate";
+ }
+ },
+ );
+ const [lockDelay, setLockDelay] = useState(() => {
+ try {
+ return Number(localStorage.getItem("find-vault-lock-delay")) || 5;
+ } catch {
+ return 5;
+ }
+ });
+ const [newRecoveryCode, setNewRecoveryCode] = useState(null);
const [thumbnailUrls, setThumbnailUrls] = useState>(
{},
);
@@ -211,7 +240,7 @@ export function VaultGallery() {
return;
}
setSessionMessage(
- "Some encrypted previews could not be loaded. You can retry shortly.",
+ "Some private previews could not be loaded. You can retry shortly.",
);
}
}
@@ -235,10 +264,16 @@ export function VaultGallery() {
if (!sessionToken) {
throw new Error("Vault session missing");
}
+ setPendingRestoreIds((current) => new Set(current).add(mediaId));
await restoreVaultItem(mediaId, sessionToken);
return mediaId;
},
onSuccess: async (mediaId) => {
+ setPendingRestoreIds((current) => {
+ const next = new Set(current);
+ next.delete(mediaId);
+ return next;
+ });
const thumbnailUrl = objectUrlsRef.current[mediaId];
if (thumbnailUrl) {
URL.revokeObjectURL(thumbnailUrl);
@@ -248,15 +283,36 @@ export function VaultGallery() {
setSessionMessage("Image restored to your timeline.");
await queryClient.invalidateQueries({ queryKey: VAULT_QUERY_KEY });
},
- onError: (error) => {
+ onError: (error, mediaId) => {
+ setPendingRestoreIds((current) => {
+ const next = new Set(current);
+ next.delete(mediaId);
+ return next;
+ });
if (isExpiredVaultSession(error)) {
clearSession("Session expired. Please unlock again.");
return;
}
setSessionMessage(
- "The image could not be restored. Its encrypted copy is still safe.",
+ "The image could not be restored. Its private vault copy is unchanged.",
+ );
+ },
+ });
+
+ const passwordMutation = useMutation({
+ mutationFn: ({ current, next }: { current: string; next: string }) =>
+ changeVaultPassword(current, next),
+ onSuccess: ({ session_token, recovery_code }) => {
+ vaultStore.getState().unlock(session_token);
+ setNewRecoveryCode(recovery_code);
+ setSessionMessage(
+ "Vault password updated. Save the new recovery code below.",
);
},
+ onError: () =>
+ setSessionMessage(
+ "Vault password could not be changed. Check the current password.",
+ ),
});
const handleLock = useCallback(() => {
@@ -277,9 +333,41 @@ export function VaultGallery() {
}
}, [queryClient, revokeThumbnails, sessionToken]);
+ useEffect(() => {
+ if (!isUnlocked) return;
+ let timer: ReturnType | undefined;
+ const lockAfter = () => {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(handleLock, lockDelay * 60_000);
+ };
+ const visibility = () => {
+ if (document.hidden) {
+ if (lockMode === "immediate") handleLock();
+ else if (lockMode === "delay") lockAfter();
+ } else if (timer && lockMode === "delay") {
+ clearTimeout(timer);
+ }
+ };
+ const activity = () => {
+ if (lockMode === "idle") lockAfter();
+ };
+ document.addEventListener("visibilitychange", visibility);
+ if (lockMode === "idle") {
+ for (const event of ["pointerdown", "keydown", "scroll"] as const)
+ window.addEventListener(event, activity, { passive: true });
+ lockAfter();
+ }
+ return () => {
+ document.removeEventListener("visibilitychange", visibility);
+ for (const event of ["pointerdown", "keydown", "scroll"] as const)
+ window.removeEventListener(event, activity);
+ if (timer) clearTimeout(timer);
+ };
+ }, [handleLock, isUnlocked, lockDelay, lockMode]);
+
const handleViewerError = useCallback(() => {
setSessionMessage(
- "The full encrypted image could not be opened. Its preview remains available.",
+ "The full private image could not be opened. Its preview remains available.",
);
}, []);
const handleSessionExpired = useCallback(() => {
@@ -310,8 +398,8 @@ export function VaultGallery() {
Locked Vault
- Originals stay encrypted at rest. This session exists in memory
- only.
+ Photos remain in private storage behind this local lock. The
+ session token exists in memory only.
@@ -325,6 +413,120 @@ export function VaultGallery() {
+
+
+
+ Vault security
+
+
+
+ Automatic lock
+
+ {(["immediate", "delay", "idle"] as const).map((mode) => (
+
+ {
+ setLockMode(mode);
+ localStorage.setItem("find-vault-lock-mode", mode);
+ }}
+ />
+ {mode === "idle" ? "When idle" : mode}
+
+ ))}
+
+
+ Delay / idle timeout{" "}
+ {
+ const value = Number(event.target.value);
+ setLockDelay(value);
+ localStorage.setItem(
+ "find-vault-lock-delay",
+ String(value),
+ );
+ }}
+ className="ml-2 rounded-lg border border-[var(--frost)] bg-[color:var(--void)] px-2 py-1"
+ >
+ 1 minute
+ 5 minutes
+ 15 minutes
+ 30 minutes
+
+
+
+
+
+ {newRecoveryCode && (
+
+
+ New one-time recovery code
+
+
+ {newRecoveryCode}
+
+
+ )}
+
+
{sessionMessage && (
{sessionMessage}
@@ -371,10 +573,7 @@ export function VaultGallery() {
restoreMutation.mutate(item.id)}
className="inline-flex items-center gap-1 rounded-full bg-black/65 px-3 py-1.5 text-xs font-medium text-white backdrop-blur transition hover:bg-black/85 disabled:cursor-wait disabled:opacity-60"
>
diff --git a/frontend/src/components/vault/VaultUnlock.tsx b/frontend/src/components/vault/VaultUnlock.tsx
index 22ef3129..663f52a3 100644
--- a/frontend/src/components/vault/VaultUnlock.tsx
+++ b/frontend/src/components/vault/VaultUnlock.tsx
@@ -1,93 +1,201 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
import axios from "axios";
+import { KeyRound, LockKeyhole, ShieldCheck } from "lucide-react";
import { useState } from "react";
import { vaultStore } from "@/store/vaultStore";
-import { unlockVault } from "./vault-client";
+import {
+ getVaultStatus,
+ recoverVault,
+ setupVault,
+ unlockVault,
+} from "./vault-client";
export function VaultUnlock() {
- const [errorMessage, setErrorMessage] = useState(null);
- const [isSubmitting, setIsSubmitting] = useState(false);
+ const status = useQuery({
+ queryKey: ["vault-status"],
+ queryFn: getVaultStatus,
+ retry: false,
+ });
+ const [mode, setMode] = useState<"unlock" | "recover">("unlock");
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [recovery, setRecovery] = useState<{
+ code: string;
+ token: string;
+ } | null>(null);
+ const initialized = status.data?.initialized ?? true;
- const handleSubmit = async (event: React.FormEvent) => {
+ const submit = async (event: React.FormEvent) => {
event.preventDefault();
-
- const form = event.currentTarget;
- const input = form.elements.namedItem("passphrase");
- if (!(input instanceof HTMLInputElement)) {
+ const data = new FormData(event.currentTarget);
+ const password = String(data.get("password") ?? "").trim();
+ const confirmation = String(data.get("confirmation") ?? "").trim();
+ const recoveryCode = String(data.get("recovery") ?? "").trim();
+ if (
+ !password ||
+ (!initialized && password !== confirmation) ||
+ (mode === "recover" && !recoveryCode)
+ ) {
+ setError(
+ !initialized && password !== confirmation
+ ? "Passwords do not match."
+ : "Complete all required fields.",
+ );
return;
}
-
- const passphrase = input.value.trim();
- if (!passphrase) {
- setErrorMessage("Enter a vault passphrase to continue.");
- input.focus();
- return;
- }
-
- setErrorMessage(null);
- setIsSubmitting(true);
-
+ setBusy(true);
+ setError(null);
try {
- const sessionToken = await unlockVault(passphrase);
- vaultStore.getState().unlock(sessionToken);
- } catch (error) {
- if (axios.isAxiosError(error) && error.response?.status === 401) {
- setErrorMessage("Incorrect vault passphrase.");
- } else if (axios.isAxiosError(error) && error.response?.status === 400) {
- const detail = error.response.data?.detail;
- setErrorMessage(
- typeof detail === "string"
- ? detail
- : "Use at least 8 characters for a new vault passphrase.",
- );
+ if (!initialized) {
+ const result = await setupVault(password);
+ setRecovery({
+ code: result.recovery_code,
+ token: result.session_token,
+ });
+ await status.refetch();
+ } else if (mode === "recover") {
+ const result = await recoverVault(recoveryCode, password);
+ setRecovery({
+ code: result.recovery_code,
+ token: result.session_token,
+ });
} else {
- setErrorMessage(
- "Vault could not be unlocked right now. Please try again.",
- );
+ vaultStore.getState().unlock(await unlockVault(password));
}
+ } catch (caught) {
+ const detail = axios.isAxiosError(caught)
+ ? caught.response?.data?.detail
+ : null;
+ setError(
+ typeof detail === "string"
+ ? detail
+ : "Vault could not be opened. Please try again.",
+ );
} finally {
- input.value = "";
- setIsSubmitting(false);
+ setBusy(false);
}
};
+ if (status.isPending)
+ return (
+
+ );
+
+ if (recovery) {
+ return (
+
+
+ Save your recovery code
+
+ This code is shown once. Keep it offline; Find stores only a one-way
+ hash.
+
+
+ {recovery.code}
+
+ vaultStore.getState().unlock(recovery.token)}
+ className="mt-5 rounded-full bg-[color:var(--near-white)] px-5 py-2.5 text-sm font-semibold text-[color:var(--void)]"
+ >
+ I saved the code
+
+
+ );
+ }
+
return (
);
}
diff --git a/frontend/src/components/vault/vault-client.ts b/frontend/src/components/vault/vault-client.ts
index c602b4f1..8201c3b4 100644
--- a/frontend/src/components/vault/vault-client.ts
+++ b/frontend/src/components/vault/vault-client.ts
@@ -23,6 +23,55 @@ export async function unlockVault(passphrase: string): Promise {
return response.data.session_token;
}
+export async function getVaultStatus(): Promise<{
+ initialized: boolean;
+ recovery_available: boolean;
+}> {
+ const response = await api.get<{
+ initialized: boolean;
+ recovery_available: boolean;
+ }>("/api/vault/status");
+ return response.data;
+}
+
+export async function setupVault(
+ passphrase: string,
+): Promise<{ session_token: string; recovery_code: string }> {
+ const response = await api.post<{
+ session_token: string;
+ recovery_code: string;
+ }>("/api/vault/setup", { passphrase });
+ return response.data;
+}
+
+export async function recoverVault(
+ recoveryCode: string,
+ newPassphrase: string,
+): Promise<{ session_token: string; recovery_code: string }> {
+ const response = await api.post<{
+ session_token: string;
+ recovery_code: string;
+ }>("/api/vault/recover", {
+ recovery_code: recoveryCode,
+ new_passphrase: newPassphrase,
+ });
+ return response.data;
+}
+
+export async function changeVaultPassword(
+ currentPassphrase: string,
+ newPassphrase: string,
+): Promise<{ session_token: string; recovery_code: string }> {
+ const response = await api.post<{
+ session_token: string;
+ recovery_code: string;
+ }>("/api/vault/password", {
+ current_passphrase: currentPassphrase,
+ new_passphrase: newPassphrase,
+ });
+ return response.data;
+}
+
export async function listVaultItems(
sessionToken: string,
): Promise {
From c74fed607893369262a802483c60906d227a3b6a Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Tue, 14 Jul 2026 13:57:40 +0530
Subject: [PATCH 11/21] feat: modernize shell settings and search
---
frontend/src/__tests__/app-shell.test.tsx | 8 +-
frontend/src/__tests__/search-page.test.tsx | 3 +
frontend/src/app/globals.css | 8 +-
frontend/src/app/search/page.tsx | 246 +++++++++++-------
frontend/src/app/settings/page.tsx | 27 +-
frontend/src/components/NavBar.tsx | 25 +-
frontend/src/components/app-shell.tsx | 175 ++++++++-----
.../src/components/appearance-settings.tsx | 62 +++++
.../src/components/map-privacy-settings.tsx | 6 +-
frontend/src/components/universal-search.tsx | 234 +++++++++++++++++
frontend/src/lib/theme.ts | 40 +++
11 files changed, 643 insertions(+), 191 deletions(-)
create mode 100644 frontend/src/components/appearance-settings.tsx
create mode 100644 frontend/src/components/universal-search.tsx
create mode 100644 frontend/src/lib/theme.ts
diff --git a/frontend/src/__tests__/app-shell.test.tsx b/frontend/src/__tests__/app-shell.test.tsx
index fe839b8a..39f7b3b4 100644
--- a/frontend/src/__tests__/app-shell.test.tsx
+++ b/frontend/src/__tests__/app-shell.test.tsx
@@ -76,7 +76,13 @@ describe("AppShell", () => {
"/account",
);
expect(
- screen.getByRole("button", { name: "Switch to dark mode" }),
+ screen.getByRole("textbox", { name: "Search everything" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByLabelText(/switch to .* mode/i),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Collapse sidebar" }),
).toBeInTheDocument();
});
diff --git a/frontend/src/__tests__/search-page.test.tsx b/frontend/src/__tests__/search-page.test.tsx
index 149bd52e..5fa7bcb5 100644
--- a/frontend/src/__tests__/search-page.test.tsx
+++ b/frontend/src/__tests__/search-page.test.tsx
@@ -4,6 +4,7 @@ import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiMocks = vi.hoisted(() => ({
+ getGallery: vi.fn(),
searchImages: vi.fn(),
submitSearchRating: vi.fn(),
}));
@@ -14,6 +15,7 @@ vi.mock("next/image", () => ({
}));
vi.mock("@/lib/api", () => ({
+ getGallery: apiMocks.getGallery,
searchImages: apiMocks.searchImages,
submitSearchRating: apiMocks.submitSearchRating,
}));
@@ -84,6 +86,7 @@ function renderWithQueryClient() {
describe("Search page", () => {
beforeEach(() => {
+ apiMocks.getGallery.mockResolvedValue({ items: [], total: 0 });
apiMocks.searchImages.mockReset();
apiMocks.submitSearchRating.mockReset();
});
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index fe35b0f8..fc7d933a 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -44,7 +44,7 @@
--status-pending-text: #ffe08a;
--status-failed-border: rgba(255, 32, 71, 0.3);
--status-failed-text: #ff9bab;
- --nav-height: 64px;
+ --nav-height: 56px;
--sidebar-width: 256px;
}
html.light,
@@ -96,7 +96,7 @@
--status-failed-border: rgba(239, 68, 68, 0.35);
--status-failed-text: #991b1b;
- --nav-height: 64px;
+ --nav-height: 56px;
--sidebar-width: 256px;
}
@@ -149,7 +149,7 @@
--status-failed-border: rgba(255, 32, 71, 0.3);
--status-failed-text: #ff9bab;
- --nav-height: 64px;
+ --nav-height: 56px;
--sidebar-width: 256px;
}
@@ -186,7 +186,7 @@
overflow-y: auto;
overflow-x: hidden;
font-synthesis: none;
- text-rendering: optimizeLegibility;
+ text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased;
}
diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx
index 802771a6..0718cb14 100644
--- a/frontend/src/app/search/page.tsx
+++ b/frontend/src/app/search/page.tsx
@@ -1,36 +1,41 @@
"use client";
-import { useMutation } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
ImageOff,
Loader2,
Search as SearchIcon,
} from "lucide-react";
-import { useEffect, useRef, useState } from "react";
+import Image from "next/image";
+import { useSearchParams } from "next/navigation";
+import { Suspense, useEffect, useState } from "react";
import { FeedbackRating } from "@/components/feedback-rating";
import { ImagePreviewModal } from "@/components/image-preview-modal";
-import { TimelineMediaView } from "@/components/timeline-media-view";
-import { type SearchResult, searchImages, submitSearchRating } from "@/lib/api";
+import {
+ getGallery,
+ type SearchResult,
+ searchImages,
+ submitSearchRating,
+} from "@/lib/api";
import { MINIO_URL_REFRESH_INTERVAL_MS, resolveMediaUrl } from "@/lib/media";
-const examples = [
- "sunset over mountains",
- "people smiling",
- "documents with text",
- "street photography at night",
-];
-
-export default function SearchPage() {
+function SearchPageContent() {
+ const searchParams = useSearchParams();
const [query, setQuery] = useState("");
const [activeQuery, setActiveQuery] = useState("");
const [allResults, setAllResults] = useState([]);
const [hasMore, setHasMore] = useState(false);
const [currentSkip, setCurrentSkip] = useState(0);
const [isLoadingMore, setIsLoadingMore] = useState(false);
- const clearedRef = useRef(false);
+ const [viewerIndex, setViewerIndex] = useState(null);
const LIMIT = 24;
+ const recentQuery = useQuery({
+ queryKey: ["search-recent-uploads"],
+ queryFn: () => getGallery({ limit: 24, sortOrder: "newest" }),
+ staleTime: 30_000,
+ });
const searchMutation = useMutation({
mutationFn: async (params: {
@@ -51,6 +56,14 @@ export default function SearchPage() {
},
});
+ useEffect(() => {
+ const initialQuery = searchParams?.get("q")?.trim();
+ if (!initialQuery || initialQuery === activeQuery) return;
+ setQuery(initialQuery);
+ setActiveQuery(initialQuery);
+ searchMutation.mutate({ searchQuery: initialQuery, limit: LIMIT, skip: 0 });
+ }, [activeQuery, searchParams, searchMutation.mutate]);
+
// Periodic refresh - update first page results without losing loaded pages
useEffect(() => {
if (!activeQuery) return;
@@ -71,7 +84,6 @@ export default function SearchPage() {
event.preventDefault();
const trimmedQuery = query.trim();
if (trimmedQuery) {
- clearedRef.current = false;
setAllResults([]);
setHasMore(false);
setCurrentSkip(0);
@@ -150,7 +162,6 @@ export default function SearchPage() {
{
- clearedRef.current = true;
setQuery("");
searchMutation.reset();
setActiveQuery("");
@@ -164,31 +175,6 @@ export default function SearchPage() {
)}
-
-
- {examples.map((example) => (
- {
- clearedRef.current = false;
- setQuery(example);
- setAllResults([]);
- setHasMore(false);
- setCurrentSkip(0);
- setActiveQuery(example);
- searchMutation.mutate({
- searchQuery: example,
- limit: LIMIT,
- skip: 0,
- });
- }}
- className="frost-button px-3 py-1.5 text-xs text-[color:var(--silver)]"
- >
- {example}
-
- ))}
-
{searchMutation.isPending && (
@@ -203,14 +189,50 @@ export default function SearchPage() {
)}
- {!searchMutation.data && !searchMutation.isPending && (
-
-
-
- Start with a place, subject, color, text, or moment.
-
-
- )}
+ {!searchMutation.data &&
+ !searchMutation.isPending &&
+ recentQuery.data && (
+
+
+
+
+ Recently uploaded
+
+
+ Your newest gallery photos, ready to search.
+
+
+
+
+ {recentQuery.data.items.map((item) => (
+
+
+
+ ))}
+
+
+ )}
{allResults.length === 0 && searchMutation.data && (
@@ -236,62 +258,76 @@ export default function SearchPage() {
- result.media_id}
- getDate={(result) => result.metadata.created_at}
- getWidth={(result) => result.metadata.width}
- getHeight={(result) => result.metadata.height}
- getThumbnailUrl={(result) =>
- resolveMediaUrl(
- result.metadata.thumbnail_url ?? result.metadata.url,
- result.metadata.minio_key,
- result.media_id,
- !result.metadata.thumbnail_url,
- )
- }
- getOriginalUrl={(result) =>
- `/api/image/${result.media_id}/original`
- }
- getAlt={(result) => result.metadata.filename}
- getOpenLabel={(result) => `Preview ${result.metadata.filename}`}
- renderItemActions={(result) => (
-
-
- {Math.round(result.similarity * 100)}%
-
-
- submitSearchRating(result.media_id, rating)
+
+ {allResults.map((result, index) => (
+
+
-
- )}
- renderViewer={({ items, index, onIndexChange, onClose }) => {
- const result = items[index];
- if (!result) return null;
- return (
-
onIndexChange(index - 1)}
- onNext={() => onIndexChange(index + 1)}
- hasPrevious={index > 0}
- hasNext={index < items.length - 1}
- onDeleted={(mediaId) => {
- setAllResults((current) =>
- current.filter((item) => item.media_id !== mediaId),
- );
- onClose();
- }}
+ setViewerIndex(index)}
+ className="absolute inset-0 z-10 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--blue)]"
/>
- );
- }}
- />
+
+
+ {Math.round(result.similarity * 100)}%
+
+
+
+ submitSearchRating(result.media_id, rating)
+ }
+ />
+
+
+
+ ))}
+
+ {viewerIndex !== null && allResults[viewerIndex] && (
+ setViewerIndex(null)}
+ onPrevious={() =>
+ setViewerIndex((current) =>
+ current === null ? null : current - 1,
+ )
+ }
+ onNext={() =>
+ setViewerIndex((current) =>
+ current === null ? null : current + 1,
+ )
+ }
+ hasPrevious={viewerIndex > 0}
+ hasNext={viewerIndex < allResults.length - 1}
+ onDeleted={(mediaId) => {
+ setAllResults((current) =>
+ current.filter((item) => item.media_id !== mediaId),
+ );
+ setViewerIndex(null);
+ }}
+ />
+ )}
{hasMore && (
@@ -318,3 +354,11 @@ export default function SearchPage() {
);
}
+
+export default function SearchPage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx
index f5f0ee8d..b762ae67 100644
--- a/frontend/src/app/settings/page.tsx
+++ b/frontend/src/app/settings/page.tsx
@@ -4,20 +4,23 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Cpu,
MapPinned,
+ Palette,
RefreshCw,
Settings2,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { AiRuntimeSettings } from "@/components/ai-runtime-settings";
+import { AppearanceSettings } from "@/components/appearance-settings";
import { HardwareAccelSettings } from "@/components/hardware-accel-settings";
import { MapPrivacySettings } from "@/components/map-privacy-settings";
import { type AppSettings, getSettings, updateSettings } from "@/lib/api";
const SECTIONS = [
+ { href: "#appearance", label: "Appearance", icon: Palette },
{ href: "#hardware", label: "Performance", icon: Cpu },
{ href: "#ai-runtime-heading", label: "Local AI", icon: Sparkles },
- { href: "#private-map", label: "Privacy & map", icon: MapPinned },
+ { href: "#privacy", label: "Privacy", icon: MapPinned },
] as const;
export default function SettingsPage() {
@@ -96,13 +99,16 @@ export default function SettingsPage() {
))}
-
+
- Runtime switches never install packages or transmit your library.
+ About runtime controls
@@ -144,6 +150,7 @@ export default function SettingsPage() {
) : (
+
save.mutate({ ml_mode })}
/>
- save.mutate({ map_enabled: enabled })}
- />
+
+
+ save.mutate({ map_enabled: enabled })
+ }
+ />
+
)}
diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx
index bd33dea7..a7fcc39f 100644
--- a/frontend/src/components/NavBar.tsx
+++ b/frontend/src/components/NavBar.tsx
@@ -2,13 +2,14 @@
import {
Archive,
- Boxes,
+ Copy,
Heart,
Images,
Library,
LockKeyhole,
type LucideIcon,
Map as MapIcon,
+ ScanSearch,
Search,
Settings,
Trash2,
@@ -49,8 +50,8 @@ export const NAV_GROUPS: NavGroup[] = [
{
label: "Utilities",
items: [
- { href: "/duplicates", label: "Duplicates", icon: Boxes },
- { href: "/clusters", label: "Clusters", icon: Boxes },
+ { href: "/duplicates", label: "Duplicates", icon: Copy },
+ { href: "/clusters", label: "Clusters", icon: ScanSearch },
{ href: "/archive", label: "Archive", icon: Archive },
{ href: "/vault", label: "Vault", icon: LockKeyhole },
{ href: "/trash", label: "Trash", icon: Trash2 },
@@ -79,9 +80,14 @@ function isCurrentRoute(pathname: string, href: string, search: string) {
type NavBarProps = {
onNavigate?: () => void;
className?: string;
+ collapsed?: boolean;
};
-export default function NavBar({ onNavigate, className }: NavBarProps) {
+export default function NavBar({
+ onNavigate,
+ className,
+ collapsed = false,
+}: NavBarProps) {
const pathname = usePathname();
const [search, setSearch] = useState("");
@@ -91,12 +97,12 @@ export default function NavBar({ onNavigate, className }: NavBarProps) {
return (
-
+
{NAV_GROUPS.map((group) => (
{group.label}
@@ -109,9 +115,10 @@ export default function NavBar({ onNavigate, className }: NavBarProps) {
- {item.label}
+
+ {item.label}
+
);
diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx
index ce4010f0..f1ee208a 100644
--- a/frontend/src/components/app-shell.tsx
+++ b/frontend/src/components/app-shell.tsx
@@ -1,13 +1,31 @@
"use client";
-import { Menu, Moon, Search, Sun, Upload, UserRound, X } from "lucide-react";
+import {
+ Menu,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Search,
+ Upload,
+ UserRound,
+ X,
+} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
-import { type ReactNode, useEffect, useRef, useState } from "react";
+import {
+ type CSSProperties,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
import NavBar from "@/components/NavBar";
-
-type Theme = "light" | "dark";
+import { UniversalSearch } from "@/components/universal-search";
+import {
+ applyThemePreference,
+ readThemePreference,
+ THEME_CHANGE_EVENT,
+} from "@/lib/theme";
type AppShellProps = {
children: ReactNode;
@@ -31,40 +49,38 @@ function isShelllessRoute(pathname: string) {
);
}
-function applyTheme(theme: Theme) {
- document.documentElement.classList.remove("light", "dark");
- document.documentElement.classList.add(theme);
- document.documentElement.dataset.theme = theme;
- document.documentElement.style.colorScheme = theme;
-}
-
export function AppShell({ children }: AppShellProps) {
const pathname = usePathname();
const router = useRouter();
const [drawerOpen, setDrawerOpen] = useState(false);
- const [theme, setTheme] = useState("light");
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const drawerRef = useRef(null);
const drawerTriggerRef = useRef(null);
const shellless = isShelllessRoute(pathname);
useEffect(() => {
- let initialTheme: Theme = "light";
-
+ const apply = () => applyThemePreference(readThemePreference());
+ const media = window.matchMedia?.("(prefers-color-scheme: dark)");
+ apply();
+ media?.addEventListener("change", apply);
+ window.addEventListener(THEME_CHANGE_EVENT, apply);
try {
- const saved = localStorage.getItem("find-theme");
- if (saved === "light" || saved === "dark") {
- initialTheme = saved;
- } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
- initialTheme = "dark";
- }
+ setSidebarCollapsed(
+ localStorage.getItem("find-sidebar-collapsed") === "true",
+ );
} catch {
- initialTheme = "light";
+ setSidebarCollapsed(false);
}
-
- applyTheme(initialTheme);
- setTheme(initialTheme);
+ return () => {
+ media?.removeEventListener("change", apply);
+ window.removeEventListener(THEME_CHANGE_EVENT, apply);
+ };
}, []);
+ useEffect(() => {
+ if (pathname) setDrawerOpen(false);
+ }, [pathname]);
+
useEffect(() => {
const handleSearchShortcut = (event: KeyboardEvent) => {
if (shellless) {
@@ -143,21 +159,31 @@ export function AppShell({ children }: AppShellProps) {
return <>{children}>;
}
- const toggleTheme = () => {
- const nextTheme: Theme = theme === "light" ? "dark" : "light";
- applyTheme(nextTheme);
+ const toggleSidebar = () => {
+ const next = !sidebarCollapsed;
+ setSidebarCollapsed(next);
try {
- localStorage.setItem("find-theme", nextTheme);
+ localStorage.setItem("find-sidebar-collapsed", String(next));
} catch {
- // Theme still applies for this session when storage is unavailable.
+ // Keep the preference for this session when storage is unavailable.
}
- setTheme(nextTheme);
};
+ const shellStyle = {
+ "--sidebar-width": sidebarCollapsed ? "76px" : "256px",
+ } as CSSProperties;
+
return (
-
-
-
+
+
+
-
+
FIND.
-
-
- Search your library
-
- /
-
-
+
@@ -214,38 +232,59 @@ export function AppShell({ children }: AppShellProps) {
Upload
-
- {theme === "light" ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- Copyright 2026 Find
-
- AGPL-3.0 License
-
+
-
+
{children}
diff --git a/frontend/src/components/appearance-settings.tsx b/frontend/src/components/appearance-settings.tsx
new file mode 100644
index 00000000..0d40288e
--- /dev/null
+++ b/frontend/src/components/appearance-settings.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { Laptop, Moon, Sun } from "lucide-react";
+import { useEffect, useState } from "react";
+import {
+ readThemePreference,
+ saveThemePreference,
+ type ThemePreference,
+} from "@/lib/theme";
+
+const OPTIONS: Array<{
+ value: ThemePreference;
+ label: string;
+ icon: typeof Sun;
+}> = [
+ { value: "light", label: "Light", icon: Sun },
+ { value: "dark", label: "Dark", icon: Moon },
+ { value: "system", label: "System", icon: Laptop },
+];
+
+export function AppearanceSettings() {
+ const [value, setValue] = useState
("system");
+ useEffect(() => setValue(readThemePreference()), []);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/map-privacy-settings.tsx b/frontend/src/components/map-privacy-settings.tsx
index 77c998a3..39fc3bb7 100644
--- a/frontend/src/components/map-privacy-settings.tsx
+++ b/frontend/src/components/map-privacy-settings.tsx
@@ -24,7 +24,7 @@ export function MapPrivacySettings({
>
-
+
@@ -49,7 +49,7 @@ export function MapPrivacySettings({
aria-checked={checked}
disabled={enabled === undefined || pending}
onClick={() => onChange(!checked)}
- className="relative h-7 w-12 shrink-0 rounded-full border border-[color:var(--frost-strong)] bg-[color:var(--surface-hover)] transition disabled:cursor-wait disabled:opacity-50 aria-checked:border-[color:var(--orange)] aria-checked:bg-[color:var(--orange)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--void)]"
+ className="relative h-7 w-12 shrink-0 rounded-full border border-[color:var(--frost-strong)] bg-[color:var(--surface-hover)] transition disabled:cursor-wait disabled:opacity-50 aria-checked:border-[color:var(--near-white)] aria-checked:bg-[color:var(--near-white)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--void)]"
>
Open map
diff --git a/frontend/src/components/universal-search.tsx b/frontend/src/components/universal-search.tsx
new file mode 100644
index 00000000..525990c2
--- /dev/null
+++ b/frontend/src/components/universal-search.tsx
@@ -0,0 +1,234 @@
+"use client";
+
+import { Archive, Heart, Images, Search, Settings, X } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ type Album,
+ getAlbums,
+ type SearchResult,
+ searchImages,
+} from "@/lib/api";
+
+const DESTINATIONS = [
+ {
+ label: "Photos",
+ detail: "Browse your library",
+ href: "/timeline",
+ icon: Images,
+ },
+ {
+ label: "Favorites",
+ detail: "Your favorite photos",
+ href: "/timeline?liked=true",
+ icon: Heart,
+ },
+ {
+ label: "Archive",
+ detail: "Archived photos",
+ href: "/archive",
+ icon: Archive,
+ },
+ {
+ label: "Settings · Appearance",
+ detail: "Light, dark, or system theme",
+ href: "/settings#appearance",
+ icon: Settings,
+ },
+ {
+ label: "Settings · Local AI",
+ detail: "AI mode and installed build",
+ href: "/settings#ai-runtime-heading",
+ icon: Settings,
+ },
+ {
+ label: "Settings · Performance",
+ detail: "CPU and GPU acceleration",
+ href: "/settings#hardware",
+ icon: Settings,
+ },
+ {
+ label: "Settings · Privacy",
+ detail: "Map and vault controls",
+ href: "/settings#privacy",
+ icon: Settings,
+ },
+] as const;
+
+export function UniversalSearch() {
+ const router = useRouter();
+ const [query, setQuery] = useState("");
+ const [open, setOpen] = useState(false);
+ const wrapperRef = useRef(null);
+ const normalized = query.trim().toLowerCase();
+ const [albums, setAlbums] = useState([]);
+ const [images, setImages] = useState([]);
+
+ useEffect(() => {
+ let active = true;
+ void getAlbums()
+ .then((result) => {
+ if (active) setAlbums(result.albums);
+ })
+ .catch(() => undefined);
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (normalized.length < 2) {
+ setImages([]);
+ return;
+ }
+ let active = true;
+ const timer = window.setTimeout(() => {
+ void searchImages({ query: normalized, limit: 5, skip: 0 })
+ .then((result) => {
+ if (active) setImages(result.results);
+ })
+ .catch(() => {
+ if (active) setImages([]);
+ });
+ }, 180);
+ return () => {
+ active = false;
+ window.clearTimeout(timer);
+ };
+ }, [normalized]);
+
+ const destinations = useMemo(() => {
+ const base = normalized
+ ? DESTINATIONS.filter((item) =>
+ `${item.label} ${item.detail}`.toLowerCase().includes(normalized),
+ )
+ : DESTINATIONS.slice(0, 4);
+ const albumMatches = albums
+ .filter(
+ (album) => !normalized || album.name.toLowerCase().includes(normalized),
+ )
+ .slice(0, 4)
+ .map((album) => ({
+ label: album.name,
+ detail: `${album.asset_count} photos · Album`,
+ href: `/albums/${album.id}`,
+ icon: Images,
+ }));
+ return [...base, ...albumMatches];
+ }, [albums, normalized]);
+
+ const navigate = (href: string) => {
+ setOpen(false);
+ setQuery("");
+ router.push(href);
+ };
+
+ return (
+ {
+ if (!wrapperRef.current?.contains(event.relatedTarget as Node | null))
+ setOpen(false);
+ }}
+ >
+
+
+ {open && (
+
+ {destinations.map((item) => {
+ const Icon = item.icon;
+ return (
+ navigate(item.href)}
+ className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left hover:bg-[color:var(--surface-hover)]"
+ >
+
+
+
+ {item.label}
+
+
+ {item.detail}
+
+
+
+ );
+ })}
+ {images.map((result) => (
+ navigate(`/gallery?media=${result.media_id}`)}
+ className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left hover:bg-[color:var(--surface-hover)]"
+ >
+
+
+
+ {result.metadata.filename}
+
+
+ Photo · {Math.round(result.similarity * 100)}% match
+
+
+
+ ))}
+ {normalized && (
+
+ navigate(`/search?q=${encodeURIComponent(query.trim())}`)
+ }
+ className="mt-1 w-full rounded-xl border border-[var(--frost)] px-3 py-2 text-left text-sm font-medium hover:bg-[color:var(--surface-hover)]"
+ >
+ Search all photos for “{query.trim()}”
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts
new file mode 100644
index 00000000..f81de17e
--- /dev/null
+++ b/frontend/src/lib/theme.ts
@@ -0,0 +1,40 @@
+export type ThemePreference = "light" | "dark" | "system";
+
+export const THEME_STORAGE_KEY = "find-theme";
+export const THEME_CHANGE_EVENT = "find-theme-change";
+
+export function resolveTheme(preference: ThemePreference): "light" | "dark" {
+ if (preference !== "system") return preference;
+ return window.matchMedia?.("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+}
+
+export function applyThemePreference(preference: ThemePreference) {
+ const theme = resolveTheme(preference);
+ document.documentElement.classList.remove("light", "dark");
+ document.documentElement.classList.add(theme);
+ document.documentElement.dataset.theme = theme;
+ document.documentElement.dataset.themePreference = preference;
+ document.documentElement.style.colorScheme = theme;
+}
+
+export function readThemePreference(): ThemePreference {
+ try {
+ const saved = localStorage.getItem(THEME_STORAGE_KEY);
+ if (saved === "light" || saved === "dark" || saved === "system") {
+ return saved;
+ }
+ } catch {
+ // Use the system preference when local storage is unavailable.
+ }
+ return "system";
+}
+
+export function saveThemePreference(preference: ThemePreference) {
+ localStorage.setItem(THEME_STORAGE_KEY, preference);
+ applyThemePreference(preference);
+ window.dispatchEvent(
+ new CustomEvent(THEME_CHANGE_EVENT, { detail: preference }),
+ );
+}
From 891af0ac18145ba4929b13332d8d9ebe15c7c922 Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Tue, 14 Jul 2026 13:58:04 +0530
Subject: [PATCH 12/21] chore: release version 1.1.1
---
README.md | 2 +-
backend/pyproject.toml | 2 +-
backend/src/find_api/__init__.py | 2 +-
backend/uv.lock | 2 +-
docs/index.md | 2 +-
frontend/package.json | 2 +-
frontend/src-tauri/Cargo.lock | 2 +-
frontend/src-tauri/Cargo.toml | 2 +-
frontend/src-tauri/tauri.conf.json | 2 +-
plan.md | 44 ++++++++++++++++++++++++++----
10 files changed, 48 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index 9003c237..ba77e9b7 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ See the documentation index in [`docs/index.md`](./docs/index.md), the mobile di
- Inspect full-resolution images with zoom, keyboard navigation, and slideshow
- Share albums with scoped links and optional passwords/download controls
- Organize media with favorites, archive, recoverable trash, and near-duplicate review
-- Protect hidden images in an encrypted local vault with explicit in-memory sessions, timeline browsing, preview, restore, and lock controls
+- Protect hidden images in a password-gated private vault with recovery, configurable auto-lock, timeline browsing, preview, and restore controls. Image bytes remain in private object storage rather than being re-encrypted.
- Record local feedback for search, captions, objects, and people grouping
## Tech stack
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index afe982ce..81c7894a 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "find-backend"
-version = "1.1.0"
+version = "1.1.1"
description = "FastAPI backend and RQ worker for Find"
license = "AGPL-3.0-only"
requires-python = ">=3.12,<3.13"
diff --git a/backend/src/find_api/__init__.py b/backend/src/find_api/__init__.py
index bf444cdb..c8ded717 100644
--- a/backend/src/find_api/__init__.py
+++ b/backend/src/find_api/__init__.py
@@ -1,3 +1,3 @@
"""Find backend application"""
-__version__ = "1.1.0"
+__version__ = "1.1.1"
diff --git a/backend/uv.lock b/backend/uv.lock
index e1441eb9..0bcbedb2 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -548,7 +548,7 @@ wheels = [
[[package]]
name = "find-backend"
-version = "1.1.0"
+version = "1.1.1"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
diff --git a/docs/index.md b/docs/index.md
index 4a817c26..3aee34c9 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -20,7 +20,7 @@ This directory is organized by document purpose and implementation status.
- [Local Search Quality Roadmap](plans/partial/local-search-quality-roadmap.md) - semantic search exists; measurable evaluation, diagnostics, reranking, and scaling work remain planned.
- [Desktop Framework Evaluation](plans/partial/desktop-tauri-vs-electron-adr.md) - Tauri spike exists, but release-grade packaging and updater decisions remain open.
- [Personalization Research](plans/partial/personalization-research.md) - feedback collection exists; adaptive personalization is still planned.
-- [Vault Encryption Design](plans/partial/vault-encryption-design.md) - vault feature exists, but implementation still needs alignment with the full design note.
+- [Legacy Vault Encryption Design](plans/partial/vault-encryption-design.md) - documents the previous encrypted-blob format retained only for safe migration of existing vault items.
- [Small-Team Authentication](plans/partial/small-team-authentication.md) - backend authentication foundations exist; frontend UI, deletion-request workflow, audit log, and Instance management UI remain planned.
### Not Started
diff --git a/frontend/package.json b/frontend/package.json
index 9555b7d5..c3a4a552 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "find-frontend",
- "version": "1.1.0",
+ "version": "1.1.1",
"packageManager": "pnpm@11.3.0",
"private": true,
"license": "AGPL-3.0-only",
diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock
index 20b5bc80..13557936 100644
--- a/frontend/src-tauri/Cargo.lock
+++ b/frontend/src-tauri/Cargo.lock
@@ -746,7 +746,7 @@ dependencies = [
[[package]]
name = "find-desktop"
-version = "1.1.0"
+version = "1.1.1"
dependencies = [
"serde",
"serde_json",
diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml
index c894d7c7..be191172 100644
--- a/frontend/src-tauri/Cargo.toml
+++ b/frontend/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "find-desktop"
-version = "1.1.0"
+version = "1.1.1"
description = "Find - Local AI Image Intelligence desktop shell"
authors = []
edition = "2021"
diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json
index 610595bd..16f6c9b2 100644
--- a/frontend/src-tauri/tauri.conf.json
+++ b/frontend/src-tauri/tauri.conf.json
@@ -1,6 +1,6 @@
{
"productName": "Find",
- "version": "1.1.0",
+ "version": "1.1.1",
"identifier": "com.abhashchakraborty.find",
"build": {
"beforeDevCommand": "pnpm dev",
diff --git a/plan.md b/plan.md
index b1c9f339..ff561963 100644
--- a/plan.md
+++ b/plan.md
@@ -4,6 +4,32 @@
> **Branch:** `abhash/production-hardening` (synchronized with `origin/main`).
> **Reference codebase:** `./reference-app/` (local, gitignored, AGPL-3.0). Read-only reference used to learn UI/behavior; **its contents are never committed to Find**.
+## 2026-07-14 v1.1.1 polish addendum
+
+- [x] Vault has separate setup, unlock, and recovery states; one-time recovery
+ codes are stored only as hashes, password changes rotate recovery codes, and
+ auto-lock supports immediate background lock, a delay, or system-style idle
+ timeout. New vault media stays in private storage without image encryption;
+ legacy encrypted items migrate after a successful old-password unlock.
+- [x] Appearance is controlled only from Settings with Light, Dark, and System
+ choices. The header theme shortcut was removed and the application header was
+ made more compact.
+- [x] The sidebar collapses to icon-only navigation with tooltips, distinct
+ duplicate/cluster icons, a one-line license footer, and the current version.
+- [x] Header and sidebar borders no longer form a crossing line, and background
+ shell regions become inert while the mobile drawer is open.
+- [x] Header search now covers photos, albums, destinations, and settings.
+ Empty image Search shows newest uploads in a normal grid; result suggestions
+ were removed and image results retain the shared preview.
+- [x] All actionable automated PR review findings were addressed, including
+ download authentication, timeline virtualization, profile conflicts, local
+ port binding, worker Redis configuration, and per-item vault restore state.
+- [x] Version metadata is aligned at `1.1.1` across web, API, and desktop.
+- [x] Live Docker and Computer-driven smoke checks verified image thumbnail and
+ full preview rendering, compact navigation, universal search, recent uploads,
+ theme switching, Settings AI/hardware controls, and vault recovery UI.
+
+
## 2026-07-13 release-candidate addendum
The local `reference-app/` checkout has been restored by the maintainer and is
@@ -30,9 +56,11 @@ It is excluded from Docker contexts and CI rejects any tracked file below
adapter exists; private media never silently falls back to a cloud service.
Verification: frontend 253 tests and the 20-route production build pass; Tauri
-`cargo check --locked` passes at v1.1.0; uv lock and all five Compose
-configurations pass; the integrated backend suite passes with 513 tests and 5
-expected platform-specific skips. Real NVIDIA inference remains a
+`cargo check --locked` passes at v1.1.1; uv lock and all five Compose
+configurations pass; the integrated backend suite passes with 516 tests and 5
+expected platform-specific skips. A seeded live-stack browser smoke verifies
+the gallery thumbnail, full preview, settings, search, vault recovery, and a
+healthy Full AI worker. Model-level NVIDIA inference remains a
hardware-specific release gate.
> **Nature of project:** Find is an **open-source** photo application. This plan reuses an open-source reference project's UI and code patterns under a compliant license (see §1).
> **Tracking:** every step is a checkbox with a status label. Update as work lands. Keep this file the single source of truth.
@@ -394,14 +422,20 @@ Every feature lane owns its tests; the program owns the final gate (§Phase 10).
- [~] **Settings panel** — panel shipped with the hardware-accel group wired to a live backend **and persisted server-side** (`/api/settings`, §5.1). Remaining groups (library/storage/ML/appearance/advanced) are YAGNI-deferred until their backends exist — not a parity gap, a scope choice.
- [~] **Hardware acceleration** — `Auto/GPU/CPU` resolution + **auto CPU fallback** implemented, unit-tested, wired into all ML inference, and **verified green on a real ubuntu+macOS+windows CI matrix** (run 28392485314). Remaining (owner-delegated): a real-GPU runner (non-fallback path) + Android/NNAPI on-device.
- [ ] **Speed** — layout + timeline query hot paths **measured and within budget** (Appendix §E: layout ~12 ms, buckets ~19.5 ms, bucket ~19.7 ms — API-level). Real-hardware / low-end-profile budgets (first paint, scroll-to-date, thumbnail throughput, CPU-mode ML latency) **owner-delegated** — need a live stack + seeded library.
-- [~] **All tests green** — the integrated v1.1 candidate passes backend **513 passed / 5 skipped** and frontend **253 passed**, plus Ruff, Biome, TypeScript, the 20-route Next.js production build, five Compose parses, and Tauri `cargo check --locked`. Full data-path browser journeys against seeded data remain owner-delegated because they need a live stack.
+- [x] **All tests green** — the integrated v1.1.1 candidate passes backend
+ **516 passed / 5 skipped** and frontend **253 passed**, plus Ruff, Biome,
+ TypeScript, the 20-route Next.js production build, five Compose parses, and
+ Tauri `cargo check --locked`. A Computer-driven seeded-stack smoke also
+ passes for thumbnail/full preview, search, shell, theme, vault recovery UI,
+ hardware detection, and Full AI worker health.
- [~] **Accessibility + security** — automated a11y smoke tests green; sharing/crypto + album/asset-state + **partner-sharing** all `/security-review`'d (each found+fixed real issues, re-reviewed RESOLVED). Timeline scrubber made keyboard-operable (CI-gated by Biome a11y). **Manual screen-reader pass owner-delegated** (human — §10.5).
- [x] **License compliant (Path A)** — Find is AGPL-3.0; `LICENSE`/`NOTICE`/metadata correct; shipped-UI footer corrected MIT→AGPL-3.0 (§9.3/§G).
- [x] **Name scrubbed** — current tree clean. *(Name-scrub CI intentionally NOT built — it would enforce stripping upstream attribution, conflicting with AGPL; attribution credited in NOTICE instead.)*
- [x] **Reference isolated** — the local checkout is ignored and optional;
independence is proven by CI on reference-free checkouts.
- [x] **Docs shipped** — hardware-accel guide ✅, features guide ✅, migration notes ✅, changelog ✅ — all linked from `docs/index.md` (§9.4).
-- [~] **Shipped** — **authorized by the owner.** Branch pushed; PR #321 open and **fully CI-green**. Merge to `main` + tag in progress (§10.6) — this pass.
+- [~] **Shipped** — **authorized by the owner.** Branch work continues in PR
+ #356; merge to `main` still requires the configured approving review.
> Legend: `[x]` done · `[~]` substantial/verified-in-part · `[ ]` not started or genuinely blocked. When the last box is genuinely `[x]`: set **Goal status → `[x] GOAL COMPLETE`**, add a final Change Log entry, and stop.
From 591e9f4d160020b0b0b469aa5f0efd0cf4372e3c Mon Sep 17 00:00:00 2001
From: Abhash Chakraborty
<80592559+Abhash-Chakraborty@users.noreply.github.com>
Date: Tue, 14 Jul 2026 18:46:38 +0530
Subject: [PATCH 13/21] feat: complete timeline and route interactions
---
.../__tests__/album-detail-viewer.test.tsx | 40 ++--
frontend/src/__tests__/asset-viewer.test.tsx | 7 +-
frontend/src/__tests__/timeline-page.test.tsx | 38 ++-
frontend/src/app/account/page.tsx | 48 ++--
frontend/src/app/albums/[id]/page.tsx | 168 ++++++++++----
frontend/src/app/archive/page.tsx | 45 +++-
frontend/src/app/clusters/page.tsx | 31 ++-
frontend/src/app/duplicates/page.tsx | 23 +-
frontend/src/app/globals.css | 9 +
frontend/src/app/image/[id]/page.tsx | 67 ++++++
frontend/src/app/people/page.tsx | 36 ++-
frontend/src/app/search/page.tsx | 28 +--
frontend/src/app/timeline/page.tsx | 208 +++++++++--------
frontend/src/app/trash/page.tsx | 47 +++-
frontend/src/app/upload/page.tsx | 38 ++-
.../src/components/album-asset-picker.tsx | 219 ++++++++++++++++++
frontend/src/components/app-shell.tsx | 4 +-
frontend/src/components/asset-viewer.tsx | 58 ++++-
.../src/components/image-preview-modal.tsx | 55 ++++-
19 files changed, 937 insertions(+), 232 deletions(-)
create mode 100644 frontend/src/app/image/[id]/page.tsx
create mode 100644 frontend/src/components/album-asset-picker.tsx
diff --git a/frontend/src/__tests__/album-detail-viewer.test.tsx b/frontend/src/__tests__/album-detail-viewer.test.tsx
index d8f3b6bc..4500601c 100644
--- a/frontend/src/__tests__/album-detail-viewer.test.tsx
+++ b/frontend/src/__tests__/album-detail-viewer.test.tsx
@@ -28,6 +28,11 @@ const api = vi.hoisted(() => ({
getSharedLinks: vi.fn(),
createSharedLink: vi.fn(),
deleteSharedLink: vi.fn(),
+ getImageDetail: vi.fn(),
+ deleteImage: vi.fn(),
+ reprocessImage: vi.fn(),
+ submitCaptionCorrection: vi.fn(),
+ submitObjectCorrection: vi.fn(),
}));
class FakeResizeObserver {
@@ -50,7 +55,11 @@ class FakeResizeObserver {
}
vi.mock("@/lib/api", () => api);
-vi.mock("@/lib/media", () => ({ resolveMediaUrl: (u: string) => u }));
+vi.mock("@/lib/media", () => ({
+ resolveMediaUrl: (u: string) => u,
+ MINIO_URL_STALE_TIME_MS: 60_000,
+ MINIO_URL_REFRESH_INTERVAL_MS: 60_000,
+}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "1" }),
@@ -99,6 +108,13 @@ beforeEach(() => {
total: 2,
});
api.getSharedLinks.mockResolvedValue({ shared_links: [], total: 0 });
+ api.getImageDetail.mockImplementation(async (id: number) => ({
+ ...item(id),
+ file_hash: `hash-${id}`,
+ url: `/api/image/${id}/original`,
+ metadata: {},
+ exif: {},
+ }));
vi.stubGlobal("ResizeObserver", FakeResizeObserver);
vi.stubGlobal("innerHeight", 5000);
vi.stubGlobal(
@@ -116,26 +132,22 @@ afterEach(() => {
});
describe("AlbumDetailPage viewer", () => {
- it("opens the AssetViewer when an album photo is clicked", async () => {
+ it("opens the metadata preview when an album photo is clicked", async () => {
renderPage();
const tile = await screen.findByTestId("open-asset-10");
fireEvent.click(tile);
await waitFor(() =>
- expect(screen.getByTestId("asset-viewer")).toBeInTheDocument(),
- );
- // Viewer shows the clicked asset's thumbnail first.
- expect(screen.getByTestId("viewer-image")).toHaveAttribute(
- "src",
- "/api/image/10/thumbnail",
+ expect(screen.getByTestId("image-preview-modal")).toBeInTheDocument(),
);
+ expect(api.getImageDetail).toHaveBeenCalledWith(10);
});
it("does not show the viewer until a photo is clicked", async () => {
renderPage();
await screen.findByTestId("open-asset-10");
- expect(screen.queryByTestId("asset-viewer")).toBeNull();
+ expect(screen.queryByTestId("image-preview-modal")).toBeNull();
});
it("archives the viewed asset and closes the viewer", async () => {
@@ -143,12 +155,12 @@ describe("AlbumDetailPage viewer", () => {
renderPage();
fireEvent.click(await screen.findByTestId("open-asset-10"));
- await screen.findByTestId("asset-viewer");
- fireEvent.click(screen.getByTestId("viewer-archive"));
+ await screen.findByTestId("image-preview-modal");
+ fireEvent.click(screen.getByTestId("preview-archive"));
await waitFor(() => expect(api.setArchive).toHaveBeenCalledWith(10, true));
await waitFor(() =>
- expect(screen.queryByTestId("asset-viewer")).toBeNull(),
+ expect(screen.queryByTestId("image-preview-modal")).toBeNull(),
);
});
@@ -160,8 +172,8 @@ describe("AlbumDetailPage viewer", () => {
renderPage();
fireEvent.click(await screen.findByTestId("open-asset-10"));
- await screen.findByTestId("asset-viewer");
- fireEvent.click(screen.getByTestId("viewer-trash"));
+ await screen.findByTestId("image-preview-modal");
+ fireEvent.click(screen.getByTestId("preview-trash"));
await waitFor(() => expect(api.trashImage).toHaveBeenCalledWith(10));
});
diff --git a/frontend/src/__tests__/asset-viewer.test.tsx b/frontend/src/__tests__/asset-viewer.test.tsx
index c7b5ae94..d3cadbb5 100644
--- a/frontend/src/__tests__/asset-viewer.test.tsx
+++ b/frontend/src/__tests__/asset-viewer.test.tsx
@@ -75,9 +75,12 @@ describe("AssetViewer", () => {
});
it("uses descriptive asset text and a safe fallback for image alternatives", () => {
+ const first = ASSETS.at(0);
+ const second = ASSETS.at(1);
+ if (!first || !second) throw new Error("Asset fixture is incomplete");
const assets = [
- { ...ASSETS[0], alt: "Sunset above the mountain ridge" },
- ASSETS[1],
+ { ...first, alt: "Sunset above the mountain ridge" },
+ second,
];
const { rerender } = render(
({
getTimelineBuckets: vi.fn(),
getTimelineBucket: vi.fn(),
+ getImageDetail: vi.fn(),
toggleLike: vi.fn(),
+ setArchive: vi.fn(),
+ trashImage: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
getTimelineBuckets: api.getTimelineBuckets,
getTimelineBucket: api.getTimelineBucket,
+ getImageDetail: api.getImageDetail,
toggleLike: api.toggleLike,
+ setArchive: api.setArchive,
+ trashImage: api.trashImage,
}));
// jsdom lacks layout + ResizeObserver; provide a fixed-width observer so the
@@ -66,11 +72,27 @@ beforeEach(() => {
vi.stubGlobal("innerHeight", 5000);
api.getTimelineBuckets.mockReset();
api.getTimelineBucket.mockReset();
+ api.getImageDetail.mockReset();
api.toggleLike.mockReset();
+ api.setArchive.mockReset();
+ api.trashImage.mockReset();
+ api.getImageDetail.mockResolvedValue({
+ id: 101,
+ filename: "photo.jpg",
+ minio_key: "images/photo.jpg",
+ file_hash: "hash",
+ status: "indexed",
+ created_at: "2026-03-01T00:00:00+00:00",
+ url: "/api/image/101/original",
+ liked: false,
+ metadata: {},
+ exif: {},
+ });
});
afterEach(() => {
cleanup();
+ window.history.replaceState(null, "", "/timeline");
vi.unstubAllGlobals();
});
@@ -153,11 +175,11 @@ describe("TimelinePage", () => {
fireEvent.click(cell);
await waitFor(() =>
- expect(screen.getByTestId("asset-viewer")).toBeInTheDocument(),
+ expect(screen.getByTestId("image-preview-modal")).toBeInTheDocument(),
);
});
- it("refetches buckets with liked=true when favorites is toggled", async () => {
+ it("loads Favorites only from its dedicated sidebar route", async () => {
api.getTimelineBuckets.mockResolvedValue({
buckets: [{ timeBucket: "2026-03-01", count: 1 }],
total: 1,
@@ -173,13 +195,13 @@ describe("TimelinePage", () => {
thumbnailUrl: ["/api/image/101/thumbnail"],
});
+ window.history.replaceState(null, "", "/timeline?liked=true");
renderPage();
- const toggle = await screen.findByTestId("timeline-favorites-toggle");
- expect(toggle).toHaveAttribute("aria-pressed", "false");
- fireEvent.click(toggle);
-
- expect(toggle).toHaveAttribute("aria-pressed", "true");
+ expect(
+ await screen.findByRole("heading", { name: "Favorites" }),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId("timeline-favorites-toggle")).toBeNull();
await waitFor(() =>
expect(api.getTimelineBuckets).toHaveBeenCalledWith(
expect.objectContaining({ liked: true }),
@@ -216,7 +238,7 @@ describe("TimelinePage", () => {
const cell = await screen.findByTestId("timeline-cell-101");
fireEvent.click(cell);
- const fav = await screen.findByTestId("viewer-favorite");
+ const fav = await screen.findByRole("button", { name: "Like image" });
fireEvent.click(fav);
await waitFor(() => expect(api.toggleLike).toHaveBeenCalledWith(101));
diff --git a/frontend/src/app/account/page.tsx b/frontend/src/app/account/page.tsx
index becfde97..20354f08 100644
--- a/frontend/src/app/account/page.tsx
+++ b/frontend/src/app/account/page.tsx
@@ -97,21 +97,39 @@ export default function AccountPage() {
if (account.data?.mode === "local" || !account.data?.user) {
return (
-
-
-
-
Private local mode
-
- This installation has no accounts. It stays single-user and local
- until you explicitly create an administrator.
-
-
- Enable accounts
-
-
+
+
+
+ System
+
+
+ /
+
+ Account
+
+
+
+
+
Private local mode
+
+ No sign-in is required and this installation remains a single-user
+ library. Your photos and AI data stay on this instance.
+
+
+
+
Need shared access?
+
+ Create the first administrator only when you want account-based
+ access for this server.
+
+
+ Enable accounts
+
+
+
);
}
diff --git a/frontend/src/app/albums/[id]/page.tsx b/frontend/src/app/albums/[id]/page.tsx
index 4a9fecd6..f3aa37e2 100644
--- a/frontend/src/app/albums/[id]/page.tsx
+++ b/frontend/src/app/albums/[id]/page.tsx
@@ -8,13 +8,24 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ArrowLeft, ImageIcon, Loader2, Star, Trash2 } from "lucide-react";
+import {
+ Archive,
+ ArrowLeft,
+ FolderPlus,
+ ImageIcon,
+ Loader2,
+ Share2,
+ Star,
+ Trash2,
+ X,
+} from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
+import { AlbumAssetPicker } from "@/components/album-asset-picker";
import { AlbumShareLinks } from "@/components/album-share-links";
-import { AssetViewer } from "@/components/asset-viewer";
+import { ImagePreviewModal } from "@/components/image-preview-modal";
import { TimelineMediaView } from "@/components/timeline-media-view";
import {
deleteAlbum,
@@ -22,7 +33,6 @@ import {
getAlbumAssets,
removeAlbumAssets,
setArchive,
- toggleLike,
trashImage,
updateAlbum,
} from "@/lib/api";
@@ -33,6 +43,8 @@ export default function AlbumDetailPage() {
const queryClient = useQueryClient();
const albumId = Number(params?.id);
const [removedIds, setRemovedIds] = useState>(new Set());
+ const [shareOpen, setShareOpen] = useState(false);
+ const [pickerOpen, setPickerOpen] = useState(false);
const { data: album, isLoading: albumLoading } = useQuery({
queryKey: ["album", albumId],
@@ -54,7 +66,8 @@ export default function AlbumDetailPage() {
const removeMutation = useMutation({
mutationFn: (mediaId: number) => removeAlbumAssets(albumId, [mediaId]),
- onSuccess: () => {
+ onSuccess: (_result, mediaId) => {
+ setRemovedIds((current) => new Set(current).add(mediaId));
invalidate();
toast.success("Removed from album");
},
@@ -81,14 +94,6 @@ export default function AlbumDetailPage() {
onError: () => toast.error("Couldn't delete album"),
});
- const favoriteMutation = useMutation({
- mutationFn: (mediaId: number) => toggleLike(mediaId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["album-assets", albumId] });
- },
- onError: () => toast.error("Couldn't update favorite"),
- });
-
const archiveMutation = useMutation({
mutationFn: (mediaId: number) => setArchive(mediaId, true),
onSuccess: ({ id }) => {
@@ -115,9 +120,6 @@ export default function AlbumDetailPage() {
// (they also fall out of the browsable query on refetch).
const allItems = assetsData?.items ?? [];
const items = allItems.filter((item) => !removedIds.has(item.id));
- const favoriteIds = new Set(
- items.filter((item) => item.liked).map((item) => item.id),
- );
return (
@@ -136,11 +138,19 @@ export default function AlbumDetailPage() {
)}
{album && (
-
+
-
- {album.name}
-
+
+
+ Albums
+
+
+ /
+
+
+ {album.name}
+
+
{album.description && (
{album.description}
)}
@@ -148,20 +158,38 @@ export default function AlbumDetailPage() {
{album.asset_count} photo{album.asset_count === 1 ? "" : "s"}
-
deleteMutation.mutate()}
- disabled={deleteMutation.isPending}
- className="inline-flex items-center gap-2 rounded-full border border-[var(--frost)] px-4 py-2 text-sm text-[color:var(--silver)] transition hover:text-[color:var(--near-white)]"
- >
- Delete album
-
+
+ setPickerOpen(true)}
+ className="white-pill px-4 py-2 text-sm font-semibold"
+ >
+ Add photos
+
+ setShareOpen((open) => !open)}
+ aria-expanded={shareOpen}
+ className="frost-button px-4 py-2 text-sm"
+ >
+ {shareOpen ? : }{" "}
+ {shareOpen ? "Close sharing" : "Share"}
+
+ deleteMutation.mutate()}
+ disabled={deleteMutation.isPending}
+ className="frost-button px-4 py-2 text-sm text-[color:var(--silver)]"
+ >
+ Delete album
+
+
)}
- {Number.isFinite(albumId) && (
-
+ {Number.isFinite(albumId) && shareOpen && (
+
)}
@@ -211,18 +239,65 @@ export default function AlbumDetailPage() {
>
)}
- renderViewer={({ viewerAssets, index, onIndexChange, onClose }) => (
-
favoriteMutation.mutate(id)}
- onArchive={(id) => archiveMutation.mutate(id)}
- onTrash={(id) => trashMutation.mutate(id)}
- />
- )}
+ renderViewer={({
+ items: viewerItems,
+ index,
+ onIndexChange,
+ onClose,
+ }) => {
+ const active = viewerItems[index];
+ if (!active) return null;
+ return (
+ onIndexChange(index - 1)}
+ onNext={() => onIndexChange(index + 1)}
+ hasPrevious={index > 0}
+ hasNext={index < viewerItems.length - 1}
+ onDeleted={(id) =>
+ setRemovedIds((current) => new Set(current).add(id))
+ }
+ actions={
+ <>
+ {
+ removeMutation.mutate(active.id);
+ onClose();
+ }}
+ >
+ Remove from album
+
+ {
+ archiveMutation.mutate(active.id);
+ onClose();
+ }}
+ >
+ Archive
+
+ {
+ trashMutation.mutate(active.id);
+ onClose();
+ }}
+ >
+ Move to trash
+
+ >
+ }
+ />
+ );
+ }}
/>
)}
@@ -232,6 +307,15 @@ export default function AlbumDetailPage() {
Album not found.
)}
+
+ {pickerOpen && Number.isFinite(albumId) && (
+ item.id))}
+ onClose={() => setPickerOpen(false)}
+ onAdded={invalidate}
+ />
+ )}
);
diff --git a/frontend/src/app/archive/page.tsx b/frontend/src/app/archive/page.tsx
index 4ef806d2..a998222e 100644
--- a/frontend/src/app/archive/page.tsx
+++ b/frontend/src/app/archive/page.tsx
@@ -9,6 +9,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArchiveRestore, Loader2 } from "lucide-react";
import { toast } from "sonner";
+import { ImagePreviewModal } from "@/components/image-preview-modal";
import { TimelineMediaView } from "@/components/timeline-media-view";
import { getArchive, setArchive } from "@/lib/api";
@@ -36,7 +37,18 @@ export default function ArchivePage() {
return (
-
Archive
+
+
+ Utilities
+
+
+ /
+
+ Archive
+
+ {items.length} photos
+
+
{isLoading && (
@@ -82,6 +94,37 @@ export default function ArchivePage() {
Unarchive
)}
+ renderViewer={({
+ items: viewerItems,
+ index,
+ onIndexChange,
+ onClose,
+ }) => {
+ const active = viewerItems[index];
+ if (!active) return null;
+ return (
+
onIndexChange(index - 1)}
+ onNext={() => onIndexChange(index + 1)}
+ hasPrevious={index > 0}
+ hasNext={index < viewerItems.length - 1}
+ actions={
+ {
+ unarchiveMutation.mutate(active.id);
+ onClose();
+ }}
+ >
+ Restore to Photos
+
+ }
+ />
+ );
+ }}
/>
)}
diff --git a/frontend/src/app/clusters/page.tsx b/frontend/src/app/clusters/page.tsx
index 49ad3399..4bdb508b 100644
--- a/frontend/src/app/clusters/page.tsx
+++ b/frontend/src/app/clusters/page.tsx
@@ -27,6 +27,7 @@ import {
getClusters,
getGallery,
getJobStatus,
+ getRuntimeConfig,
triggerClustering,
updateCluster,
} from "@/lib/api";
@@ -93,6 +94,10 @@ export default function ClustersPage() {
refetchInterval: clusterJobId ? 4000 : 10000,
staleTime: MINIO_URL_STALE_TIME_MS,
});
+ const { data: runtime } = useQuery({
+ queryKey: ["runtime-config"],
+ queryFn: getRuntimeConfig,
+ });
const selectedClusterQuery = useQuery({
queryKey: ["cluster-detail", selectedClusterId],
@@ -244,10 +249,12 @@ export default function ClustersPage() {
indexedQuery.isSuccess &&
indexedImageCount > 0 &&
indexedImageCount >= effectiveMinClusterSize;
+ const aiUnavailable = runtime ? !runtime.ai_enabled : false;
const isClusterButtonDisabled =
- isClusterActionBusy || !hasEnoughIndexedImages;
- const clusteringUnavailableMessage =
- indexedQuery.isSuccess && !hasEnoughIndexedImages
+ isClusterActionBusy || !hasEnoughIndexedImages || aiUnavailable;
+ const clusteringUnavailableMessage = aiUnavailable
+ ? "Local AI is disabled in this installed build. Enable an AI profile in Settings to cluster images."
+ : indexedQuery.isSuccess && !hasEnoughIndexedImages
? `Need at least ${effectiveMinClusterSize} indexed images to cluster. Found ${indexedImageCount}.`
: null;
@@ -266,12 +273,13 @@ export default function ClustersPage() {
return (
-
+
-
- Clusters
-
-
+
+ Library / Clusters
+
+
Clusters
+
Similar images are grouped into clean, browsable sets as your
library is indexed.
@@ -307,6 +315,13 @@ export default function ClustersPage() {
+ {aiUnavailable && (
+
+ This build imports and organizes photos without AI. Install or
+ enable a local AI profile in Settings to create visual clusters.
+
+ )}
+
{activeJobStatus && (
diff --git a/frontend/src/app/duplicates/page.tsx b/frontend/src/app/duplicates/page.tsx
index fb9dd551..65703090 100644
--- a/frontend/src/app/duplicates/page.tsx
+++ b/frontend/src/app/duplicates/page.tsx
@@ -158,12 +158,25 @@ export default function DuplicatesPage() {
return (
-
- Near-Duplicate Images
-
- {data?.total ?? 0} near-duplicate pairs found
+
+
+
+ Utilities
+
+
+ /
+
+
Duplicates
+
+ {data?.total ?? 0} similar pairs
+
+
+
+ Exact file duplicates are rejected during upload. This view finds
+ different files that look nearly identical, so it remains useful for
+ edited copies, screenshots, and recompressed photos.
-
+
{pairs.length === 0 ? (
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index fc7d933a..2655d4b1 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -272,6 +272,15 @@
scrollbar-gutter: stable;
}
+ .timeline-native-scroll {
+ scrollbar-width: none;
+ overscroll-behavior: contain;
+ }
+
+ .timeline-native-scroll::-webkit-scrollbar {
+ display: none;
+ }
+
.safe-bottom {
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
}
diff --git a/frontend/src/app/image/[id]/page.tsx b/frontend/src/app/image/[id]/page.tsx
new file mode 100644
index 00000000..4fb27eda
--- /dev/null
+++ b/frontend/src/app/image/[id]/page.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import Link from "next/link";
+import { useParams, useRouter, useSearchParams } from "next/navigation";
+import { ImagePreviewModal } from "@/components/image-preview-modal";
+import { getImageDetail } from "@/lib/api";
+
+function safeReturnPath(value: string | null): string {
+ if (!value?.startsWith("/") || value.startsWith("//")) {
+ return "/timeline";
+ }
+ return value;
+}
+
+export default function ImagePage() {
+ const params = useParams();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const mediaId = Number(params?.id);
+ const returnPath = safeReturnPath(searchParams.get("return"));
+ const image = useQuery({
+ queryKey: ["image-detail", mediaId],
+ queryFn: () => getImageDetail(mediaId),
+ enabled: Number.isFinite(mediaId) && mediaId > 0,
+ retry: false,
+ });
+
+ if (image.isPending) {
+ return (
+
+
+ Loading photo
+
+
+ );
+ }
+
+ if (image.isError || !image.data) {
+ return (
+
+
+
Photo unavailable
+
+ It may have been moved to the vault or permanently deleted.
+
+
+ Return to library
+
+
+
+ );
+ }
+
+ return (
+ router.replace(returnPath, { scroll: false })}
+ onDeleted={() => router.replace(returnPath, { scroll: false })}
+ />
+ );
+}
diff --git a/frontend/src/app/people/page.tsx b/frontend/src/app/people/page.tsx
index c0eb19f6..2bfab964 100644
--- a/frontend/src/app/people/page.tsx
+++ b/frontend/src/app/people/page.tsx
@@ -23,6 +23,7 @@ import { VirtualizedGrid } from "@/components/virtualized-grid";
import {
getPeople,
getPersonImages,
+ getRuntimeConfig,
type PersonItem,
submitPersonFeedbackWrongPerson,
triggerFaceClustering,
@@ -202,6 +203,11 @@ export default function PeoplePage() {
queryFn: getPeople,
refetchInterval: 15000,
});
+ const { data: runtime } = useQuery({
+ queryKey: ["runtime-config"],
+ queryFn: getRuntimeConfig,
+ });
+ const aiUnavailable = runtime ? !runtime.ai_enabled : false;
const selectedPersonQuery = useQuery({
queryKey: ["person-images", selectedPersonId],
@@ -251,12 +257,15 @@ export default function PeoplePage() {
{/* Page header */}
-
+
-
+
+ Library / People
+
+
People
-
+
Photos grouped by person, detected and clustered entirely on your
device.
@@ -278,7 +287,12 @@ export default function PeoplePage() {
clusterMutation.mutate()}
- disabled={clusterMutation.isPending}
+ disabled={clusterMutation.isPending || aiUnavailable}
+ title={
+ aiUnavailable
+ ? "Local AI is disabled in this installed build."
+ : undefined
+ }
className="white-pill px-5 py-2.5 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
>
{clusterMutation.isPending ? (
@@ -291,6 +305,13 @@ export default function PeoplePage() {
+ {aiUnavailable && (
+
+ Face detection is not included in this build. Photos still import
+ normally; enable a local AI profile in Settings to group people.
+
+ )}
+
{/* Loading state rendering layout */}
{isLoading && (
@@ -320,7 +341,12 @@ export default function PeoplePage() {
clusterMutation.mutate()}
- disabled={clusterMutation.isPending}
+ disabled={clusterMutation.isPending || aiUnavailable}
+ title={
+ aiUnavailable
+ ? "Local AI is disabled in this installed build."
+ : undefined
+ }
className="white-pill px-5 py-2.5 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-50"
>
diff --git a/frontend/src/app/search/page.tsx b/frontend/src/app/search/page.tsx
index 0718cb14..302172ef 100644
--- a/frontend/src/app/search/page.tsx
+++ b/frontend/src/app/search/page.tsx
@@ -119,21 +119,21 @@ function SearchPageContent() {
return (
-
-
- Search
-
-
- Describe what you remember and Find will surface the matching
- images.
-
-
+
+
+ Library
+
+
+ /
+
+ Search
+
+ Scenes, objects, captions, and visible text
+
+
-