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..b336be9a 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,20 @@ 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 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. REMOTE_ML_URL= REMOTE_ML_API_KEY= REMOTE_ML_STRIP_EXIF=true @@ -96,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 @@ -104,9 +114,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/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..9e30996c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# Default review owner for every change. +* @Abhash-Chakraborty + +# Security, release, dependency, and deployment surfaces require maintainer review. +/.github/ @Abhash-Chakraborty +/backend/pyproject.toml @Abhash-Chakraborty +/backend/uv.lock @Abhash-Chakraborty +/compose*.yml @Abhash-Chakraborty +/scripts/bump_version.py @Abhash-Chakraborty +/LICENSE @Abhash-Chakraborty +/NOTICE @Abhash-Chakraborty diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index ba3fe81a..7298c393 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,14 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - name: Contribution guide - url: https://github.com/Abhash-Chakraborty/Find/blob/main/CONTRIBUTING.md + url: https://github.com/Abhash-Chakraborty/Find/blob/canary/CONTRIBUTING.md about: Read contribution flow, checks, and PR requirements. - name: GSSoC Project Admin Guidelines url: https://gssoc.girlscript.org/guidelines/project-admin about: Program-level expectations for contributors and maintainers. + - name: Security vulnerability + url: https://github.com/Abhash-Chakraborty/Find/security/advisories/new + about: Report vulnerabilities privately; do not open a public issue. + - name: Questions and support + url: https://github.com/Abhash-Chakraborty/Find/discussions + about: Ask usage and setup questions in GitHub Discussions. diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 00000000..8ee7c7e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,28 @@ +name: Documentation problem +description: Report missing, unclear, or inaccurate documentation. +title: "docs: " +labels: [documentation, needs triage] +body: + - type: markdown + attributes: + value: Thanks for helping keep Find's documentation accurate. + - type: input + id: location + attributes: + label: Documentation location + description: Link the page or name the file and section. + validations: + required: true + - type: textarea + id: problem + attributes: + label: What is unclear or incorrect? + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Suggested improvement + description: Describe the wording, example, or structure you would prefer. + validations: + required: false diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 49620af1..9d6dc134 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,8 +2,8 @@ ## Supported Versions -Find is under active development. Security fixes are applied to the current -`main` branch. +Find is under active development. Security fixes are prepared privately, +validated through `canary`, and released from the current `main` branch. ## Reporting a Vulnerability @@ -20,3 +20,9 @@ Helpful reports include: Please allow maintainers time to review and verify reports before public disclosure so fixes can be prepared responsibly. + +Maintainers should use a patch version for a backward-compatible security fix. +After the private fix is reviewed and promoted from `canary` to `main`, the +release workflow may be manually dispatched with the emergency option to skip +the normal three-hour quiet period. The branch-policy bypass is reserved for a +last-resort production incident and does not replace review or disclosure. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..53c3e4ea --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,58 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + target-branch: canary + schedule: + interval: weekly + day: monday + time: "04:30" + timezone: Asia/Kolkata + groups: + actions: + patterns: ["*"] + + - package-ecosystem: pip + directory: /backend + target-branch: canary + schedule: + interval: weekly + day: monday + time: "05:00" + timezone: Asia/Kolkata + groups: + python-runtime: + patterns: ["*"] + + - package-ecosystem: npm + directory: /frontend + target-branch: canary + schedule: + interval: weekly + day: monday + time: "05:30" + timezone: Asia/Kolkata + groups: + frontend-runtime: + patterns: ["*"] + + - package-ecosystem: cargo + directory: /frontend/src-tauri + target-branch: canary + schedule: + interval: monthly + groups: + desktop-runtime: + patterns: ["*"] + + - package-ecosystem: docker + directory: /backend + target-branch: canary + schedule: + interval: monthly + + - package-ecosystem: docker + directory: /frontend + target-branch: canary + schedule: + interval: monthly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 184e85de..c5e722d5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,9 @@ Briefly describe the change and why it is needed. +Contributor PRs must target `canary`. Only the maintainer promotion PR from +`canary` may target `main`. + Fixes # ## Type of change @@ -12,6 +15,14 @@ Fixes # - [ ] Refactor - [ ] CI / tooling +## Release impact + +- [ ] No user-visible release note needed +- [ ] Patch (backward-compatible fix) +- [ ] Minor (backward-compatible feature) +- [ ] Major (breaking change) +- [ ] Critical/security fix (keep sensitive details private) + ## What changed - @@ -33,6 +44,7 @@ List exact steps/commands used to validate this PR. - [ ] My PR is scoped to a single issue - [ ] I followed commit message conventions - [ ] I am not committing secrets or local artifacts +- [ ] This PR targets `canary` unless it is the maintainer promotion PR ## GSSoC'26 checklist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e528f1ac..e7f4f945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,21 +5,97 @@ name: CI # Path pattern | frontend-check | backend-check | compose-check # --------------------------------|----------------|---------------|-------------- # frontend/** | yes | no | no -# backend/** | no | yes | no +# backend/**, scripts/** | 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"] + branches: ["canary", "main"] pull_request: - branches: ["main"] + branches: ["canary", "main"] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + branch-policy: + name: Branch policy + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Enforce the canary promotion path + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_BRANCH: ${{ github.base_ref }} + HEAD_BRANCH: ${{ github.head_ref }} + run: | + if [ "${EVENT_NAME}" != "pull_request" ]; then + exit 0 + fi + if [ "${BASE_BRANCH}" = "main" ] && [ "${HEAD_BRANCH}" != "canary" ]; then + echo "main only accepts promotion pull requests from canary." >&2 + exit 1 + fi + if [ "${BASE_BRANCH}" = "canary" ] && [ "${HEAD_BRANCH}" = "main" ]; then + echo "Do not merge main backwards into canary through a pull request." >&2 + exit 1 + fi + + reference-boundary: + name: Reference source boundary + runs-on: ubuntu-latest + timeout-minutes: 5 + 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 + + release-governance: + name: Release metadata and license + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + + - name: Validate synchronized version and licensing + shell: bash + run: | + python scripts/bump_version.py --check + grep -q "GNU AFFERO GENERAL PUBLIC LICENSE" LICENSE + grep -q 'license = "AGPL-3.0-only"' backend/pyproject.toml + grep -q '"license": "AGPL-3.0-only"' frontend/package.json + test -s NOTICE + + - name: Reject mutable action references + shell: bash + run: | + if grep -R -nE 'uses: [^#]+@(v[0-9]+|main|master)[[:space:]]*$' .github/workflows; then + echo "GitHub Actions must be pinned to immutable commit SHAs." >&2 + exit 1 + fi + detect-changes: runs-on: ubuntu-latest + timeout-minutes: 5 outputs: frontend: ${{ steps.filter.outputs.frontend }} backend: ${{ steps.filter.outputs.backend }} @@ -38,9 +114,10 @@ jobs: - 'frontend/**' backend: - 'backend/**' + - 'scripts/**' shared: - '.github/workflows/**' - - 'docker-compose*.yml' + - 'compose*.yml' - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d id: uncategorized @@ -51,8 +128,9 @@ jobs: - '**' - '!frontend/**' - '!backend/**' + - '!scripts/**' - '!.github/workflows/**' - - '!docker-compose*.yml' + - '!compose*.yml' - '!docs/**' - '!**/*.md' - '!LICENSE' @@ -65,6 +143,7 @@ jobs: needs: detect-changes if: needs.detect-changes.outputs.frontend == 'true' || needs.detect-changes.outputs.shared == 'true' || needs.detect-changes.outputs.uncategorized == 'true' runs-on: ubuntu-latest + timeout-minutes: 35 defaults: run: working-directory: ./frontend @@ -83,7 +162,7 @@ jobs: cache: "pnpm" cache-dependency-path: frontend/pnpm-lock.yaml - - run: pnpm install + - run: pnpm install --frozen-lockfile # Report high/critical vulnerabilities for maintainer review. - run: pnpm audit --audit-level=high || true @@ -108,6 +187,7 @@ jobs: needs: detect-changes if: needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.shared == 'true' || needs.detect-changes.outputs.uncategorized == 'true' runs-on: ubuntu-latest + timeout-minutes: 25 defaults: run: working-directory: ./backend @@ -160,6 +240,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 20 env: ML_MODE: mock defaults: @@ -193,13 +274,34 @@ 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 + timeout-minutes: 10 + 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/codeql.yml b/.github/workflows/codeql.yml index 1d6a05f2..ee9ebe80 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,13 +2,13 @@ name: CodeQL on: push: - branches: ["main"] + branches: ["canary", "main"] paths: - "backend/**" - "frontend/**" - ".github/workflows/codeql.yml" pull_request: - branches: ["main"] + branches: ["canary", "main"] paths: - "backend/**" - "frontend/**" @@ -24,18 +24,21 @@ jobs: analyze: name: Analyze (${{ matrix.language }}) runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: language: ["python", "javascript-typescript"] steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@1ad29ea4a422cce9a242a9fae469541dcd08addc with: languages: ${{ matrix.language }} - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@1ad29ea4a422cce9a242a9fae469541dcd08addc diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 6cbcfdf3..7fdf7ba7 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -10,9 +10,10 @@ permissions: jobs: label-community-issues: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Add 'needs approval' label for community-created issues - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: | // author_association is set by GitHub, based on the opener's relationship 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/pr-triage.yml b/.github/workflows/pr-triage.yml index ad43b77e..96c39093 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -39,14 +39,16 @@ jobs: triage: name: Label, link, and optionally trigger review runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout trusted workflow scripts - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: ${{ github.event.repository.default_branch }} + persist-credentials: false - name: Run PR context triage - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b env: PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} TRIGGER_MACROSCOPE: ${{ github.event_name == 'pull_request_target' && 'true' || github.event.inputs.trigger_macroscope }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 00000000..3733b16f --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,108 @@ +name: Prepare version release + +on: + workflow_dispatch: + inputs: + bump: + description: Semantic version change + required: true + default: patch + type: choice + options: [patch, minor, major] + summary: + description: Optional human release-summary bullet + required: false + type: string + +permissions: + contents: read + +concurrency: + group: prepare-release + cancel-in-progress: false + +jobs: + prepare: + name: Create version PR into canary + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + env: + GH_TOKEN: ${{ secrets.RELEASE_PR_TOKEN }} + steps: + - name: Require the release automation token + shell: bash + run: | + if [ -z "${GH_TOKEN}" ]; then + echo "Add a fine-grained RELEASE_PR_TOKEN secret with Contents and Pull requests read/write access." >&2 + exit 1 + fi + + - name: Check out canary + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: canary + fetch-depth: 0 + persist-credentials: false + + - name: Build release notes draft + shell: bash + env: + SUMMARY: ${{ inputs.summary }} + run: | + notes="${RUNNER_TEMP}/release-notes.txt" + if [ -n "${SUMMARY}" ]; then + printf '%s\n' "${SUMMARY}" > "${notes}" + else + last_tag="$(git describe --tags --abbrev=0 2>/dev/null || true)" + if [ -n "${last_tag}" ]; then + git log --no-merges --pretty='format:%s (%h)' "${last_tag}..HEAD" > "${notes}" + else + git log --no-merges -20 --pretty='format:%s (%h)' > "${notes}" + fi + fi + test -s "${notes}" || printf '%s\n' 'Maintenance release.' > "${notes}" + + - name: Update every version surface + id: version + run: python scripts/bump_version.py "${{ inputs.bump }}" --notes-file "${RUNNER_TEMP}/release-notes.txt" + + - name: Validate synchronized metadata + run: python scripts/bump_version.py --check + + - name: Commit and push release branch + id: branch + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + branch="release/v${VERSION}" + if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then + echo "Release branch ${branch} already exists." >&2 + exit 1 + fi + git switch -c "${branch}" + git config user.name "find-release-bot" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md backend/pyproject.toml backend/src/find_api/__init__.py \ + backend/uv.lock frontend/package.json frontend/src-tauri/Cargo.toml \ + frontend/src-tauri/Cargo.lock frontend/src-tauri/tauri.conf.json \ + frontend/src/components/app-shell.tsx + git commit -m "chore: prepare v${VERSION}" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"${branch}" + echo "branch=${branch}" >> "${GITHUB_OUTPUT}" + + - name: Open release PR + env: + VERSION: ${{ steps.version.outputs.version }} + BRANCH: ${{ steps.branch.outputs.branch }} + run: | + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base canary \ + --head "${BRANCH}" \ + --title "chore: prepare v${VERSION}" \ + --label release \ + --body "Automated semantic-version update for **v${VERSION}**. Review and edit the generated changelog before merging. After this reaches main through the canary promotion, the protected three-hour release timer starts." diff --git a/.github/workflows/promote-canary.yml b/.github/workflows/promote-canary.yml new file mode 100644 index 00000000..8bbf31bc --- /dev/null +++ b/.github/workflows/promote-canary.yml @@ -0,0 +1,49 @@ +name: Promote canary to main + +on: + workflow_dispatch: + inputs: + title: + description: Optional promotion PR title + required: false + default: "release: promote canary to main" + type: string + +permissions: + contents: read + +jobs: + promotion: + name: Open audited promotion PR + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: write + env: + GH_TOKEN: ${{ secrets.RELEASE_PR_TOKEN }} + steps: + - name: Require the release automation token + shell: bash + run: | + if [ -z "${GH_TOKEN}" ]; then + echo "Add RELEASE_PR_TOKEN before using automated promotions." >&2 + exit 1 + fi + + - name: Create or report promotion PR + shell: bash + env: + TITLE: ${{ inputs.title }} + run: | + existing="$(gh pr list --repo "${GITHUB_REPOSITORY}" --base main --head canary --state open --json url --jq '.[0].url // empty')" + if [ -n "${existing}" ]; then + echo "Promotion PR already open: ${existing}" + exit 0 + fi + gh pr create \ + --repo "${GITHUB_REPOSITORY}" \ + --base main \ + --head canary \ + --title "${TITLE}" \ + --label promotion \ + --body "Maintainer-controlled promotion from the tested canary branch. Only reviewed changes intended for the next production release should be included. Merging starts the three-hour release quiet period." diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..5db45474 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,183 @@ +name: Publish release images + +on: + workflow_call: + inputs: + image_tag: + description: "Image tag to publish" + required: true + type: string + backend_profile: + description: "Backend profile used when publish_all is false" + required: false + default: nvidia + type: string + publish_all: + description: "Publish every modular backend profile" + required: false + default: false + type: boolean + 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 + +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 }} + PUBLISH_ALL: ${{ inputs.publish_all }} + 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" ] || [ "${PUBLISH_ALL}" = "true" ]; 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 + permissions: + contents: read + packages: write + 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 + provenance: mode=max + sbom: 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 + permissions: + contents: read + packages: write + 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 + provenance: mode=max + sbom: 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/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..7dacf5a0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,107 @@ +name: Release main after quiet period + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + emergency: + description: Skip the three-hour delay for a critical security or production fix + required: true + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: release-main + cancel-in-progress: true + +jobs: + validate: + name: Validate releasable main version + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate synchronized semantic version + id: version + run: python scripts/bump_version.py --check + + - name: Reject an already released version + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null 2>&1; then + echo "v${VERSION} already exists. Prepare a patch, minor, or major version PR before promoting again." >&2 + exit 1 + fi + + gate: + name: ${{ inputs.emergency && 'Emergency release approval' || 'Three-hour release quiet period' }} + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: ${{ inputs.emergency && 'release-emergency' || 'release-delay' }} + steps: + - run: echo "Release gate completed for v${{ needs.validate.outputs.version }}." + + release: + name: Tag and create GitHub release + needs: [validate, gate] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + outputs: + version: ${{ needs.validate.outputs.version }} + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.validate.outputs.version }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: main + fetch-depth: 0 + + - name: Confirm main stayed stable through the gate + run: | + git fetch origin main --quiet + test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { + echo "main advanced; the newer push owns the release timer." >&2 + exit 1 + } + python scripts/bump_version.py --check + ! git show-ref --verify --quiet "refs/tags/v${VERSION}" || { + echo "v${VERSION} already exists." >&2 + exit 1 + } + + - name: Create immutable tag and release + run: | + git config user.name "find-release-bot" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v${VERSION}" -m "Find v${VERSION}" + git push origin "v${VERSION}" + gh release create "v${VERSION}" --verify-tag --generate-notes --title "Find v${VERSION}" + + publish: + name: Publish release artifacts + needs: release + uses: ./.github/workflows/publish.yml + permissions: + contents: read + packages: write + with: + image_tag: ${{ needs.release.outputs.version }} + backend_profile: nvidia + publish_all: true + secrets: inherit diff --git a/.github/workflows/stale-pr.yml b/.github/workflows/stale-pr.yml index e092b47f..fc2b626f 100644 --- a/.github/workflows/stale-pr.yml +++ b/.github/workflows/stale-pr.yml @@ -3,7 +3,7 @@ name: Stale PR Handler # When a maintainer marks a PR as stale (applies the "stale" label), this # workflow guarantees the label is present and posts a heads-up comment. # A scheduled scan then closes any PR that has carried the "stale" label -# for at least 24 hours and is still open. +# for at least seven days and is still open. on: pull_request_target: @@ -33,9 +33,9 @@ concurrency: env: STALE_LABEL: stale - STALE_COMMENT: "PR is marked as stale and will be closed in March." + STALE_COMMENT: "This PR is marked as stale. It will close automatically after 7 days without renewed activity; remove the stale label when work resumes." # Hours a PR may carry the stale label before it is auto-closed. - STALE_CLOSE_AFTER_HOURS: "24" + STALE_CLOSE_AFTER_HOURS: "168" jobs: # --------------------------------------------------------------------------- @@ -48,9 +48,10 @@ jobs: github.event_name == 'pull_request_target' && github.event.label.name == 'stale' runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Label and comment - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: | const prNumber = context.payload.pull_request.number; @@ -100,9 +101,10 @@ jobs: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Scan and close - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b with: script: | const staleLabel = process.env.STALE_LABEL; diff --git a/.github/workflows/testsprite.yml b/.github/workflows/testsprite.yml index 9cf6fce5..21ce8fa5 100644 --- a/.github/workflows/testsprite.yml +++ b/.github/workflows/testsprite.yml @@ -2,7 +2,7 @@ name: TestSprite PR Tests on: pull_request: - branches: ["main"] + branches: ["canary", "main"] types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: inputs: @@ -36,7 +36,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false - name: Check TestSprite secret id: testsprite-secret @@ -61,7 +63,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,13 +77,13 @@ 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 if: steps.testsprite-secret.outputs.available == 'true' - uses: TestSprite/run-action@v1 + uses: TestSprite/run-action@e1793b89af73cf22d267b025e827fdb94d2c4e7d with: testsprite-api-key: ${{ secrets.TESTSPRITE_API_KEY }} base_url: ${{ github.event.inputs.base_url || 'http://127.0.0.1:3000' }} @@ -89,4 +91,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 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..424159b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ This is the main shared instruction file for AI coding agents working on Find. T 4. Keep the branch focused on one issue. Do not bundle opportunistic refactors, unrelated docs, formatting churn, or cleanup. 5. Read `docs/policies/agent-security.md` before touching upload, storage, ML, face/person data, feedback, secrets, Docker, CI, or dependency files. +Contributor branches start from and target `canary`. Only the reviewed +maintainer promotion PR may target `main`; a merge to `main` is a release event. + ## Project Structure & Module Organization Find is a local-first AI image intelligence app. Key paths: @@ -31,7 +34,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 +81,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..56619442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,99 @@ 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`) +## [Unreleased] -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`. +### Changed + +- Contributor work now targets a protected `canary` default branch; only the + reviewed canary promotion can enter `main`. +- Release preparation synchronizes every version surface and uses one manual + patch/minor/major selector, followed by a restartable three-hour release gate. +- CI actions are immutable-SHA pinned, dependency updates target canary, and + release images include provenance and software bill-of-material attestations. + +## [1.1.3] — 2026-07-15 + +### Fixed + +- NVIDIA workers now use a CUDA 12/cuDNN 9-compatible ONNX Runtime and preload + the CUDA libraries installed with PyTorch before InsightFace creates model + sessions, preventing repeated `libcublasLt.so.11` errors and CPU fallback. +- Full-screen previews retain their complete action and metadata toolset, and + upload/indexing progress persists across routes until successful completion. + +## [1.1.2] — 2026-07-14 + +### Added + +- Addressable `/image/{id}` previews that retain the originating library route, + lock background scroll, expose metadata, and provide contextual archive, + trash, restore, or album-removal actions. +- Album photo picker with recent and semantic-search modes, plus automatic + trash retention controls for 7, 30, 90, custom, or never-delete policies. + +### Changed + +- Timeline pages use the date scrubber as their only visible scrollbar and + suppress it when the library cannot scroll. +- Album sharing is hidden until requested; route headers, upload, account, + search, duplicates, clusters, and people use a compact visual hierarchy. +- AI-dependent controls now report disabled or non-installed artifacts + truthfully, including unavailable GPU selection in modular CPU/no-AI builds. + +### Fixed + +- Full-resolution previews, modal navigation URLs, body scroll containment, + archive/trash restoration, and single-image timeline presentation. +- Blank private-map canvases in browsers that do not render worker-sourced + GeoJSON fills; bundled Natural Earth geometry now has a local SVG fallback. + +## [1.1.1] — 2026-07-14 + +### Added + +- Guided vault setup and unlock, one-time recovery codes, password and recovery + rotation, and configurable automatic locking. +- Collapsible desktop navigation with a compact header and universal search + across photos, albums, routes, and settings. + +### Changed + +- Account, vault, search, and settings controls now share the local-first v1.1 + application shell and remain usable across responsive layouts. + +## [1.1.0] — 2026-07-13 + +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..909e24f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,12 +10,13 @@ For repo-aware coding-agent and contributor guidance, start with [AGENTS.md](./A 1. Check open issues and comment on one before starting work. 2. Wait for maintainer confirmation/assignment. -3. Fork the repository and branch from `main`. +3. Fork the repository and branch from the default `canary` branch. ## Repository protection rules -- Direct pushes to `main` are blocked. -- Open a pull request from your branch. +- Direct pushes to `canary` and `main` are blocked for contributors. +- Open feature and fix pull requests from your branch into `canary`. +- `main` accepts only the maintainer-controlled promotion PR from `canary`. - PR merge requires: - Passing CI checks - At least one approval @@ -35,7 +36,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 +47,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 +67,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 +105,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: @@ -176,11 +184,29 @@ Each PR must: 3. Include manual test steps and expected result. 4. Add screenshots/videos for UI changes. 5. Keep scope focused to one issue. -6. Target the `main` branch. +6. Target the `canary` branch. Only a maintainer promotion PR targets `main`. 7. Pass CI checks (`frontend-check`, `backend-check`). Use the PR template in `.github/pull_request_template.md`. +## Release and version workflow + +Find uses semantic versions (`MAJOR.MINOR.PATCH`) and one maintainer-triggered +release workflow: + +- `patch` is for backward-compatible fixes and urgent maintenance. +- `minor` is for backward-compatible features. +- `major` is for breaking changes that require migration notes. + +The **Prepare version release** action opens a generated version PR into +`canary` and synchronizes backend, web, desktop, lockfile, UI, and changelog +versions. After that PR and the `canary` promotion are reviewed, merging the +promotion into `main` starts a three-hour quiet period. A newer `main` update +restarts the timer. When the timer completes, GitHub creates the immutable tag, +release, provenance/SBOM attestations, web image, and every modular backend +image. Critical fixes use a patch bump and the manual emergency release input; +sensitive reports must use a private security advisory. + ## Review expectations - Maintainers usually respond within **24-48 hours**. @@ -213,4 +239,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..e10a6587 100644 --- a/GSSOC_CONTRIBUTOR_GUIDE.md +++ b/GSSOC_CONTRIBUTOR_GUIDE.md @@ -27,18 +27,19 @@ 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 1. Find an issue labeled `good first issue`, `help wanted`, `gssoc26`, `level:beginner`, `level:intermediate`, `level:advanced`, or `level:critical`. 2. Comment on the issue and wait for maintainer assignment before starting. -3. Fork the repository and create a branch from `main`. +3. Fork the repository and create a branch from the default `canary` branch. 4. Keep the change scoped to one issue. 5. Run the relevant checks before opening a PR. -6. Open a PR against `main` and fill out the full PR template. +6. Open a PR against `canary` and fill out the full PR template. Contributors + do not open PRs directly against `main`. Do not open broad PRs that mix unrelated UI, backend, docs, and formatting changes. Small, focused PRs are faster to review. @@ -49,7 +50,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 +69,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 +121,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 +175,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 +212,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..3877e729 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

GSSoC 2026 - CI + CI Good first issue Open issues License: AGPL v3 @@ -32,27 +32,30 @@ 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 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 - **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 -| 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,17 +119,30 @@ 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. +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: @@ -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: @@ -171,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), YOLO26 nano (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:** @@ -197,7 +213,37 @@ 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 + +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. + +Maintainers prepare patch, minor, or major semantic versions with one manual +workflow. The generated version PR lands in `canary`; the reviewed +`canary`-to-`main` promotion starts a three-hour quiet period before GitHub +creates the release and publishes immutable web plus separate `no-ai`, `mock`, +`cpu`, and `nvidia` backend images. Manual publish runs can still build one +selected profile without 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 @@ -225,7 +271,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 +413,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,16 +435,16 @@ 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 1. Pick an issue and comment to get assigned. -2. Fork and create a branch from `main`. +2. Fork and create a branch from the default `canary` branch. 3. Make changes with focused commits. 4. Run quality checks from CONTRIBUTING. -5. Open a PR using the project template and link the issue. +5. Open a PR into `canary` using the project template and link the issue. ## Contribution Workflow 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/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/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/pyproject.toml b/backend/pyproject.toml index c8398b19..0ffdbf7b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "find-backend" -version = "1.0.0" +version = "1.1.3" 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.5.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.23.2", + "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.5.0,<6", @@ -42,13 +57,13 @@ ml = [ "opencv-python-headless==4.9.0.80", "ultralytics>=8.2.0", "insightface==0.7.3", - "onnxruntime-gpu==1.17.0", + "onnxruntime-gpu==1.23.2", "timm>=0.9.16", "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..51dda500 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.3" diff --git a/backend/src/find_api/core/config.py b/backend/src/find_api/core/config.py index b0805e35..793734e0 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 @@ -56,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 @@ -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..e2503cf8 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 (" @@ -253,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/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..64bdd8f2 100644 --- a/backend/src/find_api/core/hardware.py +++ b/backend/src/find_api/core/hardware.py @@ -106,6 +106,27 @@ def _safe_onnx_providers() -> list[str]: return [CPU_EP] +def preload_onnx_runtime_libraries() -> bool: + """Preload CUDA/cuDNN libraries bundled with the NVIDIA Python packages. + + ONNX Runtime 1.21+ can locate the same CUDA 12 and cuDNN 9 libraries that + the locked PyTorch wheel installs. Loading them before InsightFace creates + its sessions avoids an unusable CUDA provider silently falling back to CPU. + CPU-only artifacts do not expose ``preload_dlls`` and remain a no-op. + """ + try: + import onnxruntime as ort # type: ignore + + preload = getattr(ort, "preload_dlls", None) + if not callable(preload): + return False + preload() + return True + except Exception as exc: # noqa: BLE001 + logger.warning("Unable to preload ONNX Runtime accelerator libraries: %s", exc) + return False + + def _safe_torch_devices() -> tuple[bool, bool]: """Return (cuda_available, mps_available), never raising.""" cuda = False @@ -174,10 +195,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..4874b608 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 @@ -120,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}") @@ -304,13 +308,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 +331,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/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/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..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 AutoProcessor, AutoModelForCausalLM +from transformers import BlipForConditionalGeneration, BlipProcessor from PIL import Image import numpy as np from typing import Union @@ -12,12 +10,13 @@ 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__) 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() @@ -26,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, + trust_remote_code=False, dtype=torch_dtype, - attn_implementation="eager", ).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, @@ -63,19 +64,16 @@ 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 + "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 @@ -86,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, @@ -94,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 @@ -127,28 +115,16 @@ 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 + "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 @@ -165,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/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..c9ccdee4 100644 --- a/backend/src/find_api/ml/face_detector.py +++ b/backend/src/find_api/ml/face_detector.py @@ -2,13 +2,16 @@ import cv2 import numpy as np -from insightface.app import FaceAnalysis 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.hardware import ( + detect_capabilities, + preload_onnx_runtime_libraries, + 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__) @@ -24,10 +27,16 @@ def _load_model(self): """Loader function for ModelManager""" logger.info("Loading InsightFace model: antelopev2") + # The NVIDIA artifact gets CUDA/cuDNN from the locked PyTorch wheels. + # Make those libraries visible to ONNX Runtime before InsightFace opens + # any model sessions. This is a no-op in CPU-only artifacts. + preload_onnx_runtime_libraries() + from insightface.app import FaceAnalysis + # 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 +62,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/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/auth.py b/backend/src/find_api/routers/auth.py index 0044755a..877e3e8b 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,148 @@ 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 + + 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)} + + +@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..2cfce54a 100644 --- a/backend/src/find_api/routers/config.py +++ b/backend/src/find_api/routers/config.py @@ -12,15 +12,24 @@ 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") +TRASH_RETENTION_DAYS_KEY = "trash_retention_days" def get_setting(db: Session, key: str, default: str) -> str: @@ -34,18 +43,64 @@ def _effective_accel_mode(db: Session) -> str: return get_setting(db, ACCEL_MODE_KEY, settings.ACCEL_MODE) +def _trash_retention_days(db: Session) -> int: + raw = get_setting( + db, + TRASH_RETENTION_DAYS_KEY, + str(settings.TRASH_RETENTION_DAYS), + ) + try: + value = int(raw) + except (TypeError, ValueError): + return settings.TRASH_RETENTION_DAYS + return value if 0 <= value <= 3650 else settings.TRASH_RETENTION_DAYS + + +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 +109,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 +120,40 @@ 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] + trash_retention_days: int 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 + trash_retention_days: Optional[int] = 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), + trash_retention_days=_trash_retention_days(db), + ) + + +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 +166,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 +186,36 @@ 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.trash_retention_days is not None: + if not 0 <= request.trash_retention_days <= 3650: + raise HTTPException( + 422, + "trash_retention_days must be between 0 and 3650", + ) + _upsert_setting( + db, + TRASH_RETENTION_DAYS_KEY, + str(request.trash_retention_days), + ) + + 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..f6c8400f 100644 --- a/backend/src/find_api/routers/gallery.py +++ b/backend/src/find_api/routers/gallery.py @@ -24,7 +24,9 @@ from find_api.core.storage import get_file_url, delete_file from find_api.models.media import Media from find_api.models.cluster import Cluster +from find_api.models.app_setting import AppSetting from find_api.models.user import User +from find_api.routers.config import TRASH_RETENTION_DAYS_KEY from find_api.services.query_cache import invalidate_query_cache from find_api.workers.jobs import analyze_image, generate_thumbnail_for_media @@ -578,6 +580,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), @@ -757,6 +781,16 @@ def get_trash( user: Optional[User] = Depends(get_required_user), ): """List trashed assets (``deleted_at`` set), most recently trashed first.""" + # Retention is enforced whenever Trash is opened, so the dashboard setting + # works without requiring an external cron service. + try: + purge_expired_trash(db=db, user=user) + except Exception: # noqa: BLE001 + logger.warning( + "Trash auto-purge failed; listing trash without purging", + exc_info=True, + ) + db.rollback() query = scope_media_query( _public_media_query(db).filter(Media.deleted_at.isnot(None)), user, @@ -829,7 +863,17 @@ def purge_expired_trash( than ``TRASH_RETENTION_DAYS`` are removed. Intended for a scheduled/manual auto-purge. Retention of 0 disables age-based purging (no-op). """ - retention_days = settings.TRASH_RETENTION_DAYS + retention_setting = ( + db.query(AppSetting).filter(AppSetting.key == TRASH_RETENTION_DAYS_KEY).first() + ) + try: + retention_days = int( + retention_setting.value + if retention_setting is not None + else settings.TRASH_RETENTION_DAYS + ) + except (TypeError, ValueError): + retention_days = settings.TRASH_RETENTION_DAYS if retention_days <= 0: return { "message": "Auto-purge disabled (TRASH_RETENTION_DAYS=0)", 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..5e4e3e21 100644 --- a/backend/src/find_api/routers/vault.py +++ b/backend/src/find_api/routers/vault.py @@ -1,17 +1,20 @@ -"""Vault endpoints for unlocking, hiding, and streaming encrypted images.""" +"""Password-gated vault endpoints with legacy encrypted-item migration.""" 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,31 @@ 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.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 +from find_api.core.storage import ( + delete_file, + download_file_to_path, + get_file, + 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. @@ -50,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 @@ -59,6 +86,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): @@ -179,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() @@ -191,9 +307,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 +316,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 +372,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") @@ -249,11 +456,121 @@ def unlock_vault( ) master_key = _load_or_create_master_key(db, payload.passphrase) + try: + _migrate_legacy_encrypted_items(db, master_key) + except Exception: # noqa: BLE001 + logger.warning( + "Legacy vault migration failed during unlock; continuing with valid credentials", + exc_info=True, + ) + db.rollback() 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) + try: + _migrate_legacy_encrypted_items(db, old_key) + except Exception: # noqa: BLE001 + logger.warning( + "Legacy vault migration failed during password rotation; continuing", + exc_info=True, + ) + db.rollback() + 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), @@ -275,7 +592,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 +633,18 @@ 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" + 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 +653,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 +689,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 +717,191 @@ 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: + 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 + 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: + 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 + 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, @@ -384,6 +916,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 @@ -400,4 +944,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..db3c4f71 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 @@ -38,11 +44,41 @@ 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 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/src/find_api/workers/rq_worker.py b/backend/src/find_api/workers/rq_worker.py new file mode 100644 index 00000000..c32dcee6 --- /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 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_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..d6169791 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 @@ -1103,6 +1121,29 @@ def test_trash_view_lists_only_trashed(self, client, db): assert body["items"][0]["filename"] == "trash.jpg" assert body["items"][0]["deleted_at"] is not None + def test_trash_view_invokes_retention_purge(self, client): + with patch("find_api.routers.gallery.purge_expired_trash") as purge: + response = client.get("/api/trash") + + assert response.status_code == 200 + purge.assert_called_once() + + def test_trash_view_survives_retention_purge_failure(self, client, db): + _seed( + db, + filename="still-visible.jpg", + status="indexed", + deleted_at=datetime.now(timezone.utc), + ) + with patch( + "find_api.routers.gallery.purge_expired_trash", + side_effect=RuntimeError("purge unavailable"), + ): + response = client.get("/api/trash") + + assert response.status_code == 200 + assert response.json()["items"][0]["filename"] == "still-visible.jpg" + # --- empty trash ------------------------------------------------------- def test_empty_trash_permanently_deletes_only_trashed(self, client, db): keep = _seed(db, filename="keep.jpg", status="indexed") diff --git a/backend/tests/test_hardware.py b/backend/tests/test_hardware.py index bb95dec1..95f0db30 100644 --- a/backend/tests/test_hardware.py +++ b/backend/tests/test_hardware.py @@ -5,6 +5,9 @@ for the low-end/edge goal, so it is covered exhaustively here. """ +import sys +from types import SimpleNamespace + from find_api.core.hardware import ( CPU_EP, CUDA_EP, @@ -13,11 +16,36 @@ ROCM_EP, CapabilityReport, detect_capabilities, + preload_onnx_runtime_libraries, resolve_execution, resolve_torch_device, ) +class TestOnnxRuntimeLibraryPreload: + def test_preloads_packaged_accelerator_libraries(self, monkeypatch): + calls = [] + fake_ort = SimpleNamespace(preload_dlls=lambda: calls.append(True)) + monkeypatch.setitem(sys.modules, "onnxruntime", fake_ort) + + assert preload_onnx_runtime_libraries() is True + assert calls == [True] + + def test_is_noop_for_cpu_runtime_without_preloader(self, monkeypatch): + monkeypatch.setitem(sys.modules, "onnxruntime", SimpleNamespace()) + + assert preload_onnx_runtime_libraries() is False + + def test_preload_failure_never_breaks_cpu_fallback(self, monkeypatch): + def fail_preload(): + raise OSError("missing optional accelerator library") + + fake_ort = SimpleNamespace(preload_dlls=fail_preload) + monkeypatch.setitem(sys.modules, "onnxruntime", fake_ort) + + assert preload_onnx_runtime_libraries() is False + + def _report(providers): return CapabilityReport(available_providers=providers) 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_release_version_script.py b/backend/tests/test_release_version_script.py new file mode 100644 index 00000000..f3934ebe --- /dev/null +++ b/backend/tests/test_release_version_script.py @@ -0,0 +1,85 @@ +import datetime as dt +import importlib.util +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "bump_version.py" +SPEC = importlib.util.spec_from_file_location("find_bump_version", SCRIPT) +assert SPEC and SPEC.loader +bump = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(bump) + + +def _write(root: Path, relative: str, content: str) -> None: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _release_tree(root: Path, version: str = "1.2.3") -> None: + _write(root, "backend/pyproject.toml", f'[project]\nversion = "{version}"\n') + _write(root, "backend/src/find_api/__init__.py", f'__version__ = "{version}"\n') + _write(root, "frontend/package.json", f'{{\n "version": "{version}"\n}}\n') + _write(root, "frontend/src-tauri/Cargo.toml", f'[package]\nversion = "{version}"\n') + _write( + root, + "frontend/src-tauri/tauri.conf.json", + f'{{\n "version": "{version}"\n}}\n', + ) + _write(root, "frontend/src/components/app-shell.tsx", f"v{version}\n") + _write( + root, + "backend/uv.lock", + f'[[package]]\nname = "find-backend"\nversion = "{version}"\n', + ) + _write( + root, + "frontend/src-tauri/Cargo.lock", + f'[[package]]\nname = "find-desktop"\nversion = "{version}"\n', + ) + _write( + root, + "CHANGELOG.md", + "# Changelog\n\ndistributed under AGPL-3.0 (see `LICENSE` / `NOTICE`).\n\n", + ) + + +def test_semver_bump_levels(): + assert bump.bump_version("1.2.3", "patch") == "1.2.4" + assert bump.bump_version("1.2.3", "minor") == "1.3.0" + assert bump.bump_version("1.2.3", "major") == "2.0.0" + + +def test_updates_every_version_surface_and_changelog(tmp_path): + _release_tree(tmp_path) + + version = bump.update_versions( + tmp_path, + "minor", + notes=["Ship the canary promotion."], + release_date=dt.date(2026, 7, 15), + ) + + assert version == "1.3.0" + assert set(bump.read_versions(tmp_path).values()) == {"1.3.0"} + assert 'name = "find-backend"\nversion = "1.3.0"' in ( + tmp_path / "backend/uv.lock" + ).read_text(encoding="utf-8") + assert 'name = "find-desktop"\nversion = "1.3.0"' in ( + tmp_path / "frontend/src-tauri/Cargo.lock" + ).read_text(encoding="utf-8") + changelog = (tmp_path / "CHANGELOG.md").read_text(encoding="utf-8") + assert "## [1.3.0] — 2026-07-15" in changelog + assert "- Ship the canary promotion." in changelog + + +def test_rejects_inconsistent_versions(tmp_path): + _release_tree(tmp_path) + _write(tmp_path, "frontend/package.json", '{\n "version": "9.9.9"\n}\n') + + try: + bump.current_version(tmp_path) + except RuntimeError as exc: + assert "inconsistent" in str(exc) + else: + raise AssertionError("Expected inconsistent version metadata to fail") 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/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..be877160 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -4,12 +4,21 @@ 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"] + assert body["trash_retention_days"] == settings.TRASH_RETENTION_DAYS def test_put_persists_and_reads_back(self, client): put = client.put("/api/settings", json={"accel_mode": "cpu"}) @@ -48,3 +57,71 @@ 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_trash_retention_is_dashboard_configurable(self, client): + response = client.put("/api/settings", json={"trash_retention_days": 7}) + assert response.status_code == 200 + assert response.json()["trash_retention_days"] == 7 + assert client.get("/api/settings").json()["trash_retention_days"] == 7 + + response = client.put("/api/settings", json={"trash_retention_days": 0}) + assert response.status_code == 200 + assert response.json()["trash_retention_days"] == 0 + assert client.get("/api/settings").json()["trash_retention_days"] == 0 + + assert ( + client.put("/api/settings", json={"trash_retention_days": -1}).status_code + == 422 + ) + + 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..5e3ed693 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): @@ -136,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, } @@ -167,6 +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/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..84da4087 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, @@ -105,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", @@ -169,6 +178,172 @@ 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 + + def test_password_change_rotates_credentials_round_trip(self, client, db): + app.state.limiter.reset() + vault_router.limiter.reset() + prepare_vault_tables(db) + setup = client.post( + "/api/vault/setup", + json={"passphrase": "initial local password"}, + ) + assert setup.status_code == 200 + previous_hash = db.execute( + text("SELECT recovery_code_hash FROM vault_config WHERE id = 1") + ).scalar_one() + + app.state.limiter.reset() + vault_router.limiter.reset() + changed = client.post( + "/api/vault/password", + json={ + "current_passphrase": "initial local password", + "new_passphrase": "replacement local password", + }, + ) + assert changed.status_code == 200 + assert changed.json()["session_token"] + assert changed.json()["recovery_code"] + rotated_hash = db.execute( + text("SELECT recovery_code_hash FROM vault_config WHERE id = 1") + ).scalar_one() + assert rotated_hash != previous_hash + assert changed.json()["recovery_code"] not in rotated_hash + + app.state.limiter.reset() + vault_router.limiter.reset() + assert ( + client.post( + "/api/vault/unlock", + json={"passphrase": "initial local password"}, + ).status_code + == 401 + ) + + app.state.limiter.reset() + vault_router.limiter.reset() + assert ( + client.post( + "/api/vault/unlock", + json={"passphrase": "replacement local password"}, + ).status_code + == 200 + ) + + def test_legacy_migration_failure_does_not_block_credentials(self, client, db): + app.state.limiter.reset() + vault_router.limiter.reset() + prepare_vault_tables(db) + assert ( + client.post( + "/api/vault/setup", + json={"passphrase": "initial local password"}, + ).status_code + == 200 + ) + + app.state.limiter.reset() + vault_router.limiter.reset() + with patch.object( + vault_router, + "_migrate_legacy_encrypted_items", + side_effect=RuntimeError("corrupt legacy blob"), + ): + unlocked = client.post( + "/api/vault/unlock", + json={"passphrase": "initial local password"}, + ) + assert unlocked.status_code == 200 + + app.state.limiter.reset() + vault_router.limiter.reset() + with patch.object( + vault_router, + "_migrate_legacy_encrypted_items", + side_effect=RuntimeError("missing legacy blob"), + ): + changed = client.post( + "/api/vault/password", + json={ + "current_passphrase": "initial local password", + "new_passphrase": "replacement local password", + }, + ) + assert changed.status_code == 200 + + app.state.limiter.reset() + vault_router.limiter.reset() + assert ( + client.post( + "/api/vault/unlock", + json={"passphrase": "replacement local password"}, + ).status_code + == 200 + ) + + class TestVaultHide: """Vault hide endpoint behavior.""" @@ -231,6 +406,92 @@ 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) + db.execute( + text("UPDATE vault_config SET storage_mode = 'encrypted' WHERE id = 1") + ) + db.commit() + 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), + ] + + 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.""" + + 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 +601,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 844b88fd..ac6069e4 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.3" 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.23.2" }, + { name = "onnxruntime-gpu", marker = "extra == 'nvidia'", specifier = "==1.23.2" }, + { 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.5.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.5.0,<6" }, + { name = "transformers", marker = "extra == 'nvidia'", specifier = ">=5.5.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" @@ -832,7 +833,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.21.0" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -843,12 +844,11 @@ dependencies = [ { name = "packaging" }, { name = "pyyaml" }, { 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/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/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/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]] @@ -872,21 +872,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 +1204,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 +1214,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 +1225,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 +1234,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 +1244,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 +1257,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 +1271,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 +1279,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 +1289,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 +1304,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 +1317,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 +1327,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 +1337,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 +1346,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,9 +1372,29 @@ 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.23.2" +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/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, + { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, +] + [[package]] name = "onnxruntime-gpu" -version = "1.17.0" +version = "1.23.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coloredlogs" }, @@ -1374,8 +1405,8 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/0b/97e07445993b25a1770f50eed055dea571b815f086d95ab8d729bd5f4ca0/onnxruntime_gpu-1.17.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bb802d8033885c412269f8bc8877d8779b0dc874df6fb9df8b796cba7276ad66", size = 192111837, upload-time = "2024-01-31T18:37:19.081Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e4/b10745c8dc0d324d6dfceea9d5240a8c37d3028879107fc31d014ccaa07f/onnxruntime_gpu-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:8c43533e3e5335eaa78059fb86b849a4faded513a00c1feaaa205ca5af51c40f", size = 148631195, upload-time = "2024-01-31T18:37:32.591Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d9/b7140a4f1615195938c7e358c0804bb84271f0d6886b5cbf105c6cb58aae/onnxruntime_gpu-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f2d1f720685d729b5258ec1b36dee1de381b8898189908c98cbeecdb2f2b5c2", size = 300509596, upload-time = "2025-10-22T16:56:31.728Z" }, + { url = "https://files.pythonhosted.org/packages/87/da/2685c79e5ea587beddebe083601fead0bdf3620bc2f92d18756e7de8a636/onnxruntime_gpu-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:fe925a84b00e291e0ad3fac29bfd8f8e06112abc760cdc82cb711b4f3935bd95", size = 244508327, upload-time = "2025-10-22T16:55:19.397Z" }, ] [[package]] @@ -1388,8 +1419,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 +1518,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 +2016,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 +2105,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" @@ -2216,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]] @@ -2370,7 +2388,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 +2457,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 +2504,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 +2683,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" }, @@ -2574,17 +2691,17 @@ wheels = [ [[package]] name = "typer" -version = "0.25.1" +version = "0.26.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { 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" } +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/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, + { 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]] @@ -2658,8 +2775,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 +2794,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 +2827,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" }, ] diff --git a/compose.base.yml b/compose.base.yml new file mode 100644 index 00000000..ddacdb49 --- /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: + - "127.0.0.1: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 find_api.workers.rq_worker.FindWorker --url ${REDIS_URL} 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: + - "127.0.0.1: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/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..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. @@ -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/real-ml-troubleshooting.md b/docs/guides/real-ml-troubleshooting.md index 97cca72f..c129f77b 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 @@ -203,6 +203,25 @@ Check: nvidia-smi ``` +## ONNX Runtime requests `libcublasLt.so.11` + +An NVIDIA worker that logs `Failed to load library +libonnxruntime_providers_cuda.so` with a missing `libcublasLt.so.11` has an old +CUDA 11 ONNX Runtime inside a CUDA 12 artifact. Current locked images use the +CUDA 12/cuDNN 9 runtime and preload the NVIDIA libraries installed with +PyTorch. Rebuild and recreate the backend services so an older cached image is +not reused: + +```bash +docker compose build api worker +docker compose up -d --force-recreate api worker +``` + +Verify `GET /api/status/models` reports an `nvidia` build, `gpu` acceleration, +and a healthy worker. Do not install CUDA 11 libraries into the container as a +workaround; that defeats the locked modular artifact and leaves inference on a +mixed runtime. + ## Slow first startup Initial model downloads may take several minutes. 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/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/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/docs/plans/partial/vault-encryption-design.md b/docs/plans/partial/vault-encryption-design.md index b966f261..a7b71fb2 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.3 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/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/e2e/app-shell.spec.ts b/frontend/e2e/app-shell.spec.ts index 6b0088e9..37b8cb71 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,19 @@ 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" }), + page.getByText("Appearance, local AI, privacy, and retention."), ).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/package.json b/frontend/package.json index 569cec30..1ac4a951 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "find-frontend", - "version": "1.0.0", + "version": "1.1.3", "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/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-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index b27376fb..6e198dda 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.3" dependencies = [ "serde", "serde_json", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 996480a6..f6cff088 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.3" 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..f468f8e4 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.3", "identifier": "com.abhashchakraborty.find", "build": { "beforeDevCommand": "pnpm dev", diff --git a/frontend/src/__tests__/a11y-smoke.test.tsx b/frontend/src/__tests__/a11y-smoke.test.tsx index d3d19458..eaf1d9a8 100644 --- a/frontend/src/__tests__/a11y-smoke.test.tsx +++ b/frontend/src/__tests__/a11y-smoke.test.tsx @@ -20,6 +20,10 @@ import { HardwareAccelSettings } from "@/components/hardware-accel-settings"; import { TimelineScrubber } from "@/components/timeline-scrubber"; vi.mock("@/lib/api", () => ({ + getRuntimeConfig: vi.fn().mockResolvedValue({ + build_profile: "cpu", + ai_enabled: true, + }), getHardwareReport: vi.fn().mockResolvedValue({ accel_mode: "auto", capabilities: { 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..4500601c 100644 --- a/frontend/src/__tests__/album-detail-viewer.test.tsx +++ b/frontend/src/__tests__/album-detail-viewer.test.tsx @@ -28,10 +28,38 @@ 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 { + 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("@/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" }), @@ -80,6 +108,15 @@ 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( "Image", class { @@ -95,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 () => { @@ -122,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(), ); }); @@ -139,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__/app-shell.test.tsx b/frontend/src/__tests__/app-shell.test.tsx new file mode 100644 index 00000000..39f7b3b4 --- /dev/null +++ b/frontend/src/__tests__/app-shell.test.tsx @@ -0,0 +1,177 @@ +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("textbox", { name: "Search everything" }), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText(/switch to .* mode/i), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Collapse sidebar" }), + ).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..d3cadbb5 100644 --- a/frontend/src/__tests__/asset-viewer.test.tsx +++ b/frontend/src/__tests__/asset-viewer.test.tsx @@ -74,6 +74,42 @@ 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 = [ + { ...first, alt: "Sunset above the mountain ridge" }, + second, + ]; + 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__/hardware-accel-settings.test.tsx b/frontend/src/__tests__/hardware-accel-settings.test.tsx index 2b1815e7..1048dc7e 100644 --- a/frontend/src/__tests__/hardware-accel-settings.test.tsx +++ b/frontend/src/__tests__/hardware-accel-settings.test.tsx @@ -14,12 +14,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { HardwareAccelSettings } from "@/components/hardware-accel-settings"; import type { HardwareReport } from "@/lib/api"; -const { getHardwareReport } = vi.hoisted(() => ({ +const { getHardwareReport, getRuntimeConfig } = vi.hoisted(() => ({ getHardwareReport: vi.fn(), + getRuntimeConfig: vi.fn().mockResolvedValue({ + build_profile: "nvidia", + ai_enabled: true, + }), })); vi.mock("@/lib/api", () => ({ getHardwareReport, + getRuntimeConfig, })); function renderWithClient(ui: React.ReactElement) { diff --git a/frontend/src/__tests__/map-page.test.tsx b/frontend/src/__tests__/map-page.test.tsx new file mode 100644 index 00000000..570a64bc --- /dev/null +++ b/frontend/src/__tests__/map-page.test.tsx @@ -0,0 +1,171 @@ +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 + +
+ ), +})); + +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("http://localhost:8000/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__/search-page.test.tsx b/frontend/src/__tests__/search-page.test.tsx index 149bd52e..e69da24f 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(); }); @@ -167,4 +170,43 @@ describe("Search page", () => { ).toBeInTheDocument(); }); }); + + it("does not reapply the original deep-link query after Clear", async () => { + window.history.replaceState(null, "", "/search?q=sunset"); + apiMocks.searchImages.mockResolvedValue({ + results: [], + total: 0, + query: "", + page: 1, + limit: 24, + skip: 0, + has_more: false, + }); + + renderWithQueryClient(); + await waitFor(() => + expect(apiMocks.searchImages).toHaveBeenCalledWith({ + query: "sunset", + limit: 24, + skip: 0, + }), + ); + + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + const input = screen.getByPlaceholderText( + /a visual memory, object, scene, or mood/i, + ); + fireEvent.change(input, { target: { value: "mountain" } }); + fireEvent.click(screen.getByRole("button", { name: /^search$/i })); + + await waitFor(() => + expect(apiMocks.searchImages).toHaveBeenLastCalledWith({ + query: "mountain", + limit: 24, + skip: 0, + }), + ); + expect(apiMocks.searchImages).toHaveBeenCalledTimes(2); + window.history.replaceState(null, "", "/search"); + }); }); diff --git a/frontend/src/__tests__/settings-page.test.tsx b/frontend/src/__tests__/settings-page.test.tsx index 22b52579..9791eef0 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,30 @@ 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"], + trash_retention_days: 30, + ...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 +107,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 +127,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 +145,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) => ( + + )} + />, + ); + 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__/timeline-page.test.tsx b/frontend/src/__tests__/timeline-page.test.tsx index 6fb21df0..299ad761 100644 --- a/frontend/src/__tests__/timeline-page.test.tsx +++ b/frontend/src/__tests__/timeline-page.test.tsx @@ -16,16 +16,30 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import TimelinePage from "../app/timeline/page"; +const navigation = vi.hoisted(() => ({ + useSearchParams: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useSearchParams: navigation.useSearchParams, +})); + const api = vi.hoisted(() => ({ 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 @@ -54,11 +68,20 @@ function renderPage() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); - return render( + const view = render( , ); + return { + ...view, + rerenderPage: () => + view.rerender( + + + , + ), + }; } beforeEach(() => { @@ -66,11 +89,30 @@ beforeEach(() => { vi.stubGlobal("innerHeight", 5000); api.getTimelineBuckets.mockReset(); api.getTimelineBucket.mockReset(); + api.getImageDetail.mockReset(); api.toggleLike.mockReset(); + api.setArchive.mockReset(); + api.trashImage.mockReset(); + navigation.useSearchParams.mockImplementation( + () => new URLSearchParams(window.location.search), + ); + 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 +195,19 @@ describe("TimelinePage", () => { fireEvent.click(cell); await waitFor(() => - expect(screen.getByTestId("asset-viewer")).toBeInTheDocument(), + expect(screen.getByTestId("image-preview-modal")).toBeInTheDocument(), ); + expect(screen.getByTestId("image-preview-modal")).toHaveClass( + "h-dvh", + "w-full", + ); + const detailsToggle = screen.getByTestId("preview-details-toggle"); + expect(detailsToggle).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(detailsToggle); + expect(detailsToggle).toHaveAttribute("aria-expanded", "true"); }); - 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 +223,33 @@ 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( + await screen.findByRole("heading", { name: "Favorites" }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("timeline-favorites-toggle")).toBeNull(); + await waitFor(() => + expect(api.getTimelineBuckets).toHaveBeenCalledWith( + expect.objectContaining({ liked: true }), + ), + ); + }); + + it("reacts when same-route navigation toggles the Favorites query", async () => { + api.getTimelineBuckets.mockResolvedValue({ buckets: [], total: 0 }); + const view = renderPage(); + expect( + await screen.findByRole("heading", { name: "Photos" }), + ).toBeInTheDocument(); + + window.history.replaceState(null, "", "/timeline?liked=true"); + view.rerenderPage(); - expect(toggle).toHaveAttribute("aria-pressed", "true"); + expect( + await screen.findByRole("heading", { name: "Favorites" }), + ).toBeInTheDocument(); await waitFor(() => expect(api.getTimelineBuckets).toHaveBeenCalledWith( expect.objectContaining({ liked: true }), @@ -216,7 +286,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/__tests__/upload-queue.test.ts b/frontend/src/__tests__/upload-queue.test.ts new file mode 100644 index 00000000..02beb7e6 --- /dev/null +++ b/frontend/src/__tests__/upload-queue.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + getUploadItemProgress, + useUploadQueueStore, +} from "@/store/uploadQueueStore"; + +describe("persistent upload queue", () => { + beforeEach(() => { + localStorage.clear(); + useUploadQueueStore.getState().clearAll(); + }); + + it("tracks upload, indexing progress, and clears completed work", () => { + const queue = useUploadQueueStore.getState(); + queue.beginUpload(); + queue.setUploadProgress(52); + expect(useUploadQueueStore.getState()).toMatchObject({ + phase: "uploading", + uploadProgress: 52, + }); + + queue.completeUpload({ + total: 1, + results: [ + { + filename: "memory.jpg", + status: "uploaded", + media_id: 7, + job_id: "job-7", + }, + ], + }); + let item = useUploadQueueStore.getState().items[0]; + expect(item?.processingState).toBe("queued"); + + queue.updateJob({ + job_id: "job-7", + status: "started", + stage: "generating caption", + }); + item = useUploadQueueStore.getState().items[0]; + expect(item?.processingState).toBe("processing"); + expect(item ? getUploadItemProgress(item) : 0).toBe(62); + + queue.updateJob({ job_id: "job-7", status: "finished" }); + item = useUploadQueueStore.getState().items[0]; + expect(item?.processingState).toBe("indexed"); + expect(item ? getUploadItemProgress(item) : 0).toBe(100); + + queue.clearCompleted(); + expect(useUploadQueueStore.getState().items).toEqual([]); + }); + + it("persists queued job identifiers for navigation and reload recovery", () => { + useUploadQueueStore.getState().completeUpload({ + total: 1, + results: [ + { + filename: "persistent.jpg", + status: "uploaded", + job_id: "persistent-job", + }, + ], + }); + + expect(localStorage.getItem("find-upload-queue-v1")).toContain( + "persistent-job", + ); + }); +}); diff --git a/frontend/src/__tests__/vault-gallery.test.tsx b/frontend/src/__tests__/vault-gallery.test.tsx new file mode 100644 index 00000000..858643fe --- /dev/null +++ b/frontend/src/__tests__/vault-gallery.test.tsx @@ -0,0 +1,172 @@ +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(), + changeVaultPassword: vi.fn(), + getVaultStatus: vi.fn(), + setupVault: vi.fn(), + recoverVault: vi.fn(), + unlockVault: 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.getVaultStatus.mockResolvedValue({ + initialized: true, + recovery_available: true, + }); + 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( + await screen.findByRole("button", { name: /unlock vault/i }), + ).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..20354f08 --- /dev/null +++ b/frontend/src/app/account/page.tsx @@ -0,0 +1,314 @@ +"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 ( +
+
+ + 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 + +
+
+
+ ); + } + + const user = account.data.user; + return ( +
+
+
+

+ {user.role} +

+

Account settings

+
+ +
+ +
+
+

+ Profile +

+
) => { + event.preventDefault(); + profile.mutate({ username, display_name: displayName }); + }} + > + + +
+ + {profile.isSuccess && ( + + Saved + + )} + {profile.isError && ( + + {extractErrorMessage( + profile.error, + "Could not save profile.", + )} + + )} +
+
+
+ +
+

+ Password +

+
) => { + event.preventDefault(); + password.mutate({ + current_password: currentPassword, + new_password: newPassword, + }); + }} + > + + +
+ + + Changing it signs out every other session. + + {password.isSuccess && ( + + Password changed + + )} + {password.isError && ( + + {extractErrorMessage( + password.error, + "Could not change password.", + )} + + )} +
+
+
+ +
+

Active sessions

+
+ {sessions.data?.map((session) => ( +
+
+

+ {session.current ? "This browser" : "Signed-in session"} +

+

+ Expires {new Date(session.expires_at).toLocaleString()} +

+
+ +
+ ))} + {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..c1ad08e2 100644 --- a/frontend/src/app/albums/[id]/page.tsx +++ b/frontend/src/app/albums/[id]/page.tsx @@ -8,32 +8,43 @@ */ 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, getAlbum, getAlbumAssets, removeAlbumAssets, setArchive, - toggleLike, 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 [shareOpen, setShareOpen] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); const { data: album, isLoading: albumLoading } = useQuery({ queryKey: ["album", albumId], @@ -55,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"); }, @@ -82,18 +94,11 @@ 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 }) => { setRemovedIds((cur) => new Set(cur).add(id)); + invalidate(); queryClient.invalidateQueries({ queryKey: ["album-assets", albumId] }); queryClient.invalidateQueries({ queryKey: ["archive"] }); toast.success("Archived"); @@ -105,6 +110,7 @@ export default function AlbumDetailPage() { mutationFn: (mediaId: number) => trashImage(mediaId), onSuccess: ({ id }) => { setRemovedIds((cur) => new Set(cur).add(id)); + invalidate(); queryClient.invalidateQueries({ queryKey: ["album-assets", albumId] }); queryClient.invalidateQueries({ queryKey: ["trash"] }); toast.success("Moved to trash"); @@ -116,9 +122,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 (
@@ -137,11 +140,19 @@ export default function AlbumDetailPage() { )} {album && ( -
+
-

- {album.name} -

+
+ + Albums + + +

+ {album.name} +

+
{album.description && (

{album.description}

)} @@ -149,20 +160,38 @@ export default function AlbumDetailPage() { {album.asset_count} photo{album.asset_count === 1 ? "" : "s"}

- +
+ + + +
)} - {Number.isFinite(albumId) && ( -
+ {Number.isFinite(albumId) && shareOpen && ( +
)} @@ -173,41 +202,25 @@ export default function AlbumDetailPage() {
)} - {!assetsLoading && items.length === 0 && ( -

- This album has no photos yet. -

- )} - -
    - {items.map((item, index) => ( -
  • - -
    + {!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={({ + 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)); + invalidate(); + }} + actions={ + <> + + + + + } + /> + ); + }} + /> + )} {!album && !albumLoading && (

@@ -237,24 +310,16 @@ export default function AlbumDetailPage() { Album not found.

)} -
- {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)} - /> - )} + {pickerOpen && Number.isFinite(albumId) && ( + item.id))} + onClose={() => setPickerOpen(false)} + onAdded={invalidate} + /> + )} +
); } 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..a998222e 100644 --- a/frontend/src/app/archive/page.tsx +++ b/frontend/src/app/archive/page.tsx @@ -9,8 +9,9 @@ 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"; -import { resolveMediaUrl } from "@/lib/media"; export default function ArchivePage() { const queryClient = useQueryClient(); @@ -36,7 +37,18 @@ export default function ArchivePage() { return (
-

Archive

+
+ + Utilities + + +

Archive

+ + {items.length} photos + +
{isLoading && (
@@ -50,44 +62,71 @@ 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 */} - {item.filename} + {!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) => ( -
  • - ))} -
+ )} + 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={ + + } + /> + ); + }} + /> + )}
); 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 + +
+ ) : ( +
+ + + {login.isError && ( +

+ {extractErrorMessage(login.error, "Unable to sign in.")} +

+ )} + +
+ )} +
+
+ ); +} 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. +

+
+ + + + + {password !== confirmPassword && confirmPassword.length > 0 && ( +

+ Passwords do not match. +

+ )} + {setup.isError && ( +

+ {extractErrorMessage( + setup.error, + "Unable to create the account.", + )} +

+ )} + +
+ + Keep using local mode + +
+
+ ); +} diff --git a/frontend/src/app/clusters/page.tsx b/frontend/src/app/clusters/page.tsx index 04ff83d4..359dfee9 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, @@ -34,6 +35,7 @@ import { MINIO_URL_STALE_TIME_MS, resolveMediaUrl, } from "@/lib/media"; +import { useAiAvailability } from "@/lib/use-ai-availability"; function formatJobStatus(status?: string) { switch (status) { @@ -85,12 +87,14 @@ 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, refetchInterval: clusterJobId ? 4000 : 10000, staleTime: MINIO_URL_STALE_TIME_MS, }); + const { aiUnavailable, unavailableMessage } = useAiAvailability(); const selectedClusterQuery = useQuery({ queryKey: ["cluster-detail", selectedClusterId], @@ -243,9 +247,10 @@ export default function ClustersPage() { indexedImageCount > 0 && indexedImageCount >= effectiveMinClusterSize; const isClusterButtonDisabled = - isClusterActionBusy || !hasEnoughIndexedImages; - const clusteringUnavailableMessage = - indexedQuery.isSuccess && !hasEnoughIndexedImages + isClusterActionBusy || !hasEnoughIndexedImages || aiUnavailable; + const clusteringUnavailableMessage = aiUnavailable + ? unavailableMessage + : indexedQuery.isSuccess && !hasEnoughIndexedImages ? `Need at least ${effectiveMinClusterSize} indexed images to cluster. Found ${indexedImageCount}.` : null; @@ -264,12 +269,13 @@ export default function ClustersPage() { return (
-
+
-

- Clusters -

-

+

+ Library / Clusters +

+

Clusters

+

Similar images are grouped into clean, browsable sets as your library is indexed.

@@ -305,6 +311,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 && (
@@ -398,7 +411,8 @@ export default function ClustersPage() {
-
+
{selectedClusterQuery.isLoading && (
@@ -643,63 +660,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 ( - - ); - }} + ) + } + 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/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/gallery/page.tsx b/frontend/src/app/gallery/page.tsx index 6000fe40..8d64cce4 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 && ( - )} - 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 ( -
+
+ - -
-

- {item.filename} -

-
- - {downloadUrl && ( - - - - )} - {(item.status === "failed" || - (item.status === "indexed" && !item.caption)) && ( - - )} - {isVaultUnlocked && vaultSessionToken && ( - - )} - -
-
-
+ + {(item.status === "failed" || + (item.status === "indexed" && !item.caption)) && ( + + )} + {isVaultUnlocked && vaultSessionToken && ( + + )} + +
); }} /> diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a3a23be4..2655d4b1 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: 56px; + --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: 56px; + --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: 56px; + --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,46 @@ @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); + .page-surface { + min-height: calc(100dvh - var(--nav-height)); + width: 100%; + padding-inline: clamp(1rem, 3.5vw, 3rem); } - body:has(.home-shell) { - overflow: hidden; + .app-shell-scrollbar { + scrollbar-gutter: stable; } - .container-shell { - width: min(1180px, calc(100% - 32px)); - margin: 0 auto; + .timeline-native-scroll { + scrollbar-width: none; + overscroll-behavior: contain; } - .hero-viewport { - height: 100%; + .timeline-native-scroll::-webkit-scrollbar { + display: none; } - .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; + .safe-bottom { + padding-bottom: max(1.5rem, env(safe-area-inset-bottom)); + } + + .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 +466,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 +487,6 @@ } } -@media (max-width: 600px) { - @layer components { - .hero-viewport { - height: 100%; - } - } -} - @keyframes fade-up { from { opacity: 0; @@ -494,16 +498,6 @@ } } -@keyframes soft-pulse { - 0%, - 100% { - opacity: 0.72; - } - 50% { - opacity: 1; - } -} - @keyframes scan { 0%, 45% { @@ -513,8 +507,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/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/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({ - - - - {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 ( + + ); +} + +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 ( +
+
+
+
+
+

+ Photo map +

+

+ Explore the locations already embedded in your photos. Find uses a + bundled world map and never contacts a tile provider or geocoder. +

+
+ + {markersQuery.data?.enabled && ( +
+ Map filters +
+ )} +
+ + {markersQuery.isPending ? ( +
+
+
+
+ ) : markersQuery.isError ? ( +
+
+
+
+ ) : !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 && ( +
+
+ )} + +
+ + {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..0a02627c 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, @@ -28,6 +29,7 @@ import { updatePersonName, } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/media"; +import { useAiAvailability } from "@/lib/use-ai-availability"; // ─── Person Card Component ──────────────────────────────────────────────────── @@ -189,6 +191,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, @@ -200,6 +203,7 @@ export default function PeoplePage() { queryFn: getPeople, refetchInterval: 15000, }); + const { aiUnavailable, unavailableMessage } = useAiAvailability(); const selectedPersonQuery = useQuery({ queryKey: ["person-images", selectedPersonId], @@ -249,12 +253,15 @@ export default function PeoplePage() {
{/* Page header */} -
+
-

+

+ Library / People +

+

People

-

+

Photos grouped by person, detected and clustered entirely on your device.

@@ -276,7 +283,8 @@ 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 && (
@@ -318,7 +333,8 @@ export default function PeoplePage() {
-
+
{selectedPersonQuery.isLoading && (
@@ -425,77 +444,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" > - -
-

- {img.faces.length}{" "} - {img.faces.length === 1 ? "face" : "faces"} detected -

- -
-
+ {wrongPersonMutation.isPending + ? "Saving…" + : "Wrong person"} + ); }} /> diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 2dcb4512..35620ccc 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { type ReactNode, useEffect, useState } from "react"; import { Toaster } from "sonner"; +import { UploadQueueMonitor } from "@/components/upload-status-indicator"; import { vaultStore } from "@/store/vaultStore"; interface ProvidersProps { @@ -48,6 +49,7 @@ export function Providers({ children }: ProvidersProps) { return ( + {children} 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..2a2f64f1 100644 --- a/frontend/src/app/search/page.tsx +++ b/frontend/src/app/search/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowRight, ImageOff, @@ -8,32 +8,35 @@ import { Search as SearchIcon, } from "lucide-react"; import Image from "next/image"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Suspense, 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 { 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 [selectedMediaId, setSelectedMediaId] = useState(null); 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 initializedFromUrlRef = useRef(false); 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: { @@ -54,6 +57,21 @@ export default function SearchPage() { }, }); + useEffect(() => { + if (initializedFromUrlRef.current) return; + const initialQuery = ( + searchParams?.get("q") ?? + (typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("q") + : null) + )?.trim(); + if (!initialQuery) return; + initializedFromUrlRef.current = true; + setQuery(initialQuery); + setActiveQuery(initialQuery); + searchMutation.mutate({ searchQuery: initialQuery, limit: LIMIT, skip: 0 }); + }, [searchParams, searchMutation.mutate]); + // Periodic refresh - update first page results without losing loaded pages useEffect(() => { if (!activeQuery) return; @@ -74,8 +92,6 @@ export default function SearchPage() { event.preventDefault(); const trimmedQuery = query.trim(); if (trimmedQuery) { - clearedRef.current = false; - setSelectedMediaId(null); setAllResults([]); setHasMore(false); setCurrentSkip(0); @@ -108,46 +124,24 @@ 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 (
-
-

- Search -

-

- Describe what you remember and Find will surface the matching - images. -

-
+
+ + Library + + +

Search

+ + Scenes, objects, captions, and visible text + +
-
-
+ +
@@ -176,10 +170,8 @@ export default function SearchPage() { )}
- -
- {examples.map((example) => ( - - ))} -
{searchMutation.isPending && ( @@ -231,14 +197,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) => ( + + {item.filename} + + ))} +
+
+ )} {allResults.length === 0 && searchMutation.data && (
@@ -264,88 +266,76 @@ export default function SearchPage() {

- result.media_id} - renderItem={(result) => { - const imageSrc = resolveMediaUrl( - result.metadata.thumbnail_url ?? result.metadata.url, - result.metadata.minio_key, - result.media_id, - !result.metadata.thumbnail_url, - ); - - return ( -
- - -
+
+ {allResults.map((result, index) => ( +
+ {result.metadata.filename} +
- ); - }} - /> + +
+
+ ))} +
+ {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 && (
@@ -369,25 +359,14 @@ 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); - } - }} - /> - )}
); } + +export default function SearchPage() { + return ( + }> + + + ); +} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 8e30b340..12c80bd1 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -1,47 +1,205 @@ "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, + Palette, + RefreshCw, + Settings2, + ShieldCheck, + Sparkles, + Trash2, +} from "lucide-react"; +import { AiRuntimeSettings } from "@/components/ai-runtime-settings"; +import { AppearanceSettings } from "@/components/appearance-settings"; import { HardwareAccelSettings } from "@/components/hardware-accel-settings"; -import { type AccelMode, getSettings, updateSettings } from "@/lib/api"; +import { MapPrivacySettings } from "@/components/map-privacy-settings"; +import { TrashRetentionSettings } from "@/components/trash-retention-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: "#privacy", label: "Privacy", icon: MapPinned }, + { href: "#trash-retention", label: "Trash", icon: Trash2 }, +] 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. -

- )} +
+
+
+
+
+
+ + System + + +

+ Settings +

+
+

+ Appearance, local AI, privacy, and retention. +

+
+
+ {save.isPending && ( + + + )} + {save.isSuccess && !save.isPending && ( + + + )} +
+
+
+ +
+ + +
+ {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. +

+ +
+ ) : ( +
+ + 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.mutate({ trash_retention_days }) + } + /> +
+ )} + + {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..dcddc1cb 100644 --- a/frontend/src/app/timeline/page.tsx +++ b/frontend/src/app/timeline/page.tsx @@ -11,11 +11,22 @@ */ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AssetViewer } from "@/components/asset-viewer"; +import { Archive, Trash2 } from "lucide-react"; +import { useSearchParams } from "next/navigation"; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { toast } from "sonner"; +import { ImagePreviewModal } from "@/components/image-preview-modal"; import { JustifiedGrid } from "@/components/justified-grid"; import { TimelineScrubber } from "@/components/timeline-scrubber"; -import { setArchive, toggleLike, trashImage } from "@/lib/api"; +import { setArchive, trashImage } from "@/lib/api"; +import { resolveMediaUrl } from "@/lib/media"; import { buildScrubberLayout, offsetToSegment, @@ -23,55 +34,45 @@ import { } from "@/lib/timeline-scrubber"; import { useTimeline } from "@/lib/use-timeline"; -export default function TimelinePage() { +function TimelinePageContent() { const queryClient = useQueryClient(); - const [likedOnly, setLikedOnly] = useState(false); - const { buckets, assets, total, isLoadingBuckets, isError, loadBucket } = - useTimeline({ - liked: likedOnly || undefined, - }); + const searchParams = useSearchParams(); + const likedOnly = searchParams + ? searchParams.get("liked") === "true" + : 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 [canScroll, setCanScroll] = useState(false); const [viewerIndex, setViewerIndex] = useState(null); - // Local favorite overrides for instant feedback (the per-bucket cache isn't - // refetched on a like toggle, only on a filter change). - const [favoriteOverrides, setFavoriteOverrides] = useState< - Record - >({}); // Assets archived/trashed from the viewer leave the grid immediately; the // 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)), [assets, removedIds], ); - const favoriteIds = useMemo(() => { - const ids = new Set(); - for (const a of assets) { - const overridden = favoriteOverrides[a.id]; - if (overridden ?? a.liked) { - ids.add(a.id); - } - } - return ids; - }, [assets, favoriteOverrides]); - - const favoriteMutation = useMutation({ - mutationFn: (mediaId: number) => toggleLike(mediaId), - onSuccess: ({ id, liked }) => { - setFavoriteOverrides((cur) => ({ ...cur, [id]: liked })); - queryClient.invalidateQueries({ queryKey: ["gallery-counts"] }); - }, - }); - const archiveMutation = useMutation({ mutationFn: (mediaId: number) => setArchive(mediaId, true), onSuccess: ({ id }) => { setRemovedIds((cur) => new Set(cur).add(id)); queryClient.invalidateQueries({ queryKey: ["archive"] }); }, + onError: () => toast.error("Failed to archive photo. Please try again."), }); const trashMutation = useMutation({ @@ -80,6 +81,8 @@ export default function TimelinePage() { setRemovedIds((cur) => new Set(cur).add(id)); queryClient.invalidateQueries({ queryKey: ["trash"] }); }, + onError: () => + toast.error("Failed to move photo to trash. Please try again."), }); // Once buckets are known, eagerly load the first bucket so the grid has @@ -91,10 +94,82 @@ 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); + } + }, + { root: scrollRef.current, 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(() => { + const container = scrollRef.current; + if (!container || scrubberLayout.totalHeight <= 0) return; + + const update = () => { + const scrollable = Math.max( + 0, + container.scrollHeight - container.clientHeight, + ); + setCanScroll(scrollable > 2); + const fraction = + scrollable > 0 + ? Math.min(1, Math.max(0, container.scrollTop / scrollable)) + : 0; + const offset = fraction * scrubberLayout.totalHeight; + setScrollOffset(offset); + const segment = offsetToSegment(scrubberLayout, offset); + if (segment) loadBucket(segment.timeBucket); + }; + + update(); + container.addEventListener("scroll", update, { passive: true }); + const observer = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); + observer?.observe(container); + return () => { + container.removeEventListener("scroll", update); + observer?.disconnect(); + }; + }, [loadBucket, scrubberLayout]); + + const visibleAssetCount = visibleAssets.length; + useEffect(() => { + if (visibleAssetCount === 0) { + setCanScroll(false); + return; + } + const frame = requestAnimationFrame(() => { + const container = scrollRef.current; + if (container) { + setCanScroll(container.scrollHeight - container.clientHeight > 2); + } + }); + return () => cancelAnimationFrame(frame); + }, [visibleAssetCount]); const handleScrub = useCallback( (offset: number) => { setScrollOffset(offset); @@ -102,37 +177,46 @@ export default function TimelinePage() { if (segment) { loadBucket(segment.timeBucket); } - if (typeof window !== "undefined") { + const container = scrollRef.current; + if (container) { const fraction = offsetToTrackFraction(scrubberLayout, offset); - const doc = document.documentElement; - const scrollable = Math.max(0, doc.scrollHeight - window.innerHeight); - window.scrollTo({ top: fraction * scrollable, behavior: "auto" }); + const scrollable = Math.max( + 0, + container.scrollHeight - container.clientHeight, + ); + container.scrollTo({ top: fraction * scrollable, behavior: "auto" }); } }, [scrubberLayout, loadBucket], ); - - const viewerAssets = visibleAssets.map((a) => ({ - id: a.id, - thumbnailUrl: a.thumbnailUrl, - originalUrl: `/api/image/${a.id}`, - })); + const activeViewerAsset = + viewerIndex === null ? null : (visibleAssets[viewerIndex] ?? null); return ( -
-
-

Timeline

- {!isLoadingBuckets && ( -

{total} photos

- )} - +
+
+
+ + Library + + +

+ {likedOnly ? "Favorites" : "Photos"} +

+ {!isLoadingBuckets && ( +

+ {total} photos +

+ )} +
{isLoadingBuckets && ( @@ -151,9 +235,14 @@ export default function TimelinePage() {

No photos yet.

)} -
-
+
+
a.id} renderItem={(asset, index) => ( @@ -163,38 +252,105 @@ export default function TimelinePage() { onClick={() => setViewerIndex(index)} style={{ width: "100%", height: "100%", padding: 0, border: 0 }} > - {/* biome-ignore lint/a11y/useAltText: thumbnail tile */} + {/* biome-ignore lint/performance/noImgElement: authenticated API thumbnail */} )} /> +
+ {nextBucket ? "Loading more of your timeline…" : null} +
- {buckets.length > 0 && ( - + {canScroll && buckets.length > 1 && ( + )}
- {viewerIndex !== null && viewerAssets[viewerIndex] && ( - setViewerIndex(null)} - favoriteIds={favoriteIds} - onToggleFavorite={(id) => favoriteMutation.mutate(id)} - onArchive={(id) => archiveMutation.mutate(id)} - onTrash={(id) => trashMutation.mutate(id)} + onPrevious={() => + setViewerIndex((current) => (current === null ? null : current - 1)) + } + onNext={() => + setViewerIndex((current) => (current === null ? null : current + 1)) + } + hasPrevious={viewerIndex > 0} + hasNext={viewerIndex < visibleAssets.length - 1} + onDeleted={(id) => + setRemovedIds((current) => new Set(current).add(id)) + } + actions={ + <> + + + + } /> )}
); } + +export default function TimelinePage() { + return ( + + } + > + + + ); +} diff --git a/frontend/src/app/trash/page.tsx b/frontend/src/app/trash/page.tsx index df754309..fe73ef27 100644 --- a/frontend/src/app/trash/page.tsx +++ b/frontend/src/app/trash/page.tsx @@ -9,8 +9,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, RotateCcw, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { ImagePreviewModal } from "@/components/image-preview-modal"; +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(); @@ -49,8 +50,19 @@ export default function TrashPage() { return (
-
-

Trash

+
+
+ + Utilities + + +

Trash

+ + {items.length} photos + +
{items.length > 0 && ( - - ))} - + )} + 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={ + + } + /> + ); + }} + /> + )}
); diff --git a/frontend/src/app/upload/page.tsx b/frontend/src/app/upload/page.tsx index ae0e6e81..a037b323 100644 --- a/frontend/src/app/upload/page.tsx +++ b/frontend/src/app/upload/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, CheckCircle, @@ -11,50 +11,25 @@ import { XCircle, } from "lucide-react"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; +import { UploadStatusRing } from "@/components/upload-status-indicator"; import { extractErrorMessage, - getJobStatus, - type JobStatus, - type UploadResponse, - type UploadResult, + getRuntimeConfig, uploadImages, uploadImagesBulk, } from "@/lib/api"; +import { + getUploadItemProgress, + type UploadQueueItem, + useUploadQueueStore, +} from "@/store/uploadQueueStore"; type UploadMode = "single" | "bulk"; -type ProcessingState = "queued" | "processing" | "indexed" | "failed"; - -type UploadListItem = UploadResult & { - jobStatus?: JobStatus["status"]; - processingState?: ProcessingState; - processingStage?: string; -}; - -function hydrateResults(response: UploadResponse) { - return response.results.map((result) => ({ - ...result, - jobStatus: result.status === "uploaded" ? "queued" : undefined, - processingState: result.status === "uploaded" ? "queued" : undefined, - })); -} -function getProcessingState(jobStatus?: JobStatus["status"]): ProcessingState { - if (jobStatus === "finished") { - return "indexed"; - } - if (jobStatus === "failed") { - return "failed"; - } - if (jobStatus === "started") { - return "processing"; - } - return "queued"; -} - -function getDisplayStatus(item: UploadListItem) { +function getDisplayStatus(item: UploadQueueItem) { if (item.status === "duplicate") { return "duplicate"; } @@ -73,7 +48,7 @@ function getDisplayStatus(item: UploadListItem) { return "queued"; } -function getDisplayStage(item: UploadListItem) { +function getDisplayStage(item: UploadQueueItem) { if (item.status !== "uploaded") { return null; } @@ -86,51 +61,7 @@ function getDisplayStage(item: UploadListItem) { return item.processingStage ?? item.processingState ?? "queued"; } -const STAGE_PROGRESS: Record = { - queued: 8, - started: 16, - processing: 20, - "loading image": 22, - "extracting exif": 34, - "generating mock metadata": 48, - "detecting objects": 48, - "generating caption": 62, - "running ocr": 74, - "generating embedding": 88, - "indexing complete": 96, - "detecting faces": 96, - "clustering queued": 98, - indexed: 100, - failed: 100, -}; - -function normalizeStage(stage?: string) { - return stage?.trim().toLowerCase(); -} - -function getItemProgress(item: UploadListItem): number { - if (item.status === "failed" || item.processingState === "failed") { - return 100; - } - if (item.processingState === "indexed") { - return 100; - } - - const stage = normalizeStage(item.processingStage); - const stageProgress = stage ? STAGE_PROGRESS[stage] : undefined; - if (stageProgress !== undefined) { - return stageProgress; - } - if (item.processingState === "processing" || item.jobStatus === "started") { - return STAGE_PROGRESS.processing ?? 20; - } - if (item.processingState === "queued" || item.jobStatus === "queued") { - return STAGE_PROGRESS.queued ?? 8; - } - return 0; -} - -function getStatusClasses(item: UploadListItem) { +function getStatusClasses(item: UploadQueueItem) { if (item.status === "duplicate") { return "accent-badge status-pending"; } @@ -147,9 +78,26 @@ function getStatusClasses(item: UploadListItem) { } export default function UploadPage() { - const [uploadedFiles, setUploadedFiles] = useState([]); + const uploadedFiles = useUploadQueueStore((state) => state.items); + const uploadPhase = useUploadQueueStore((state) => state.phase); + const uploadProgress = useUploadQueueStore((state) => state.uploadProgress); + const beginUpload = useUploadQueueStore((state) => state.beginUpload); + const setUploadProgress = useUploadQueueStore( + (state) => state.setUploadProgress, + ); + const completeUpload = useUploadQueueStore((state) => state.completeUpload); + const failUpload = useUploadQueueStore((state) => state.failUpload); const [mode, setMode] = useState("single"); const queryClient = useQueryClient(); + const runtime = useQuery({ + queryKey: ["runtime-config"], + queryFn: getRuntimeConfig, + retry: false, + }); + const localAiActive = + runtime.data?.ai_enabled && + (runtime.data.applied_mode === "full" || + runtime.data.applied_mode === "mock"); const parsedUploadLimit = Number( process.env.NEXT_PUBLIC_MAX_UPLOAD_SIZE_MB ?? "50", @@ -167,50 +115,7 @@ export default function UploadPage() { ? Math.floor(parsedBulkLimit) : 200; - const uploadMutation = useMutation({ - mutationFn: uploadImages, - onSuccess: (data) => { - setUploadedFiles((prev) => [...hydrateResults(data), ...prev]); - void queryClient.invalidateQueries({ queryKey: ["gallery"] }); - toast.success( - `Queued ${data.total} file${data.total === 1 ? "" : "s"} for analysis`, - ); - }, - onError: (error) => { - toast.error(extractErrorMessage(error, "Upload failed")); - }, - }); - - const bulkUploadMutation = useMutation({ - mutationFn: uploadImagesBulk, - onSuccess: (data) => { - setUploadedFiles((prev) => [...hydrateResults(data), ...prev]); - void queryClient.invalidateQueries({ queryKey: ["gallery"] }); - const uploadedCount = data.results.filter( - (item) => item.status === "uploaded", - ).length; - const failedResults = data.results.filter( - (item) => item.status === "failed" && item.error, - ); - toast.success( - `Archive accepted (${uploadedCount} new upload${ - uploadedCount === 1 ? "" : "s" - })`, - ); - if (failedResults.length > 0) { - toast.error( - failedResults.length === 1 - ? failedResults[0]?.error - : `${failedResults.length} files failed. ${failedResults[0]?.error}`, - ); - } - }, - onError: (error) => { - toast.error(extractErrorMessage(error, "Bulk upload failed")); - }, - }); - - const isUploading = uploadMutation.isPending || bulkUploadMutation.isPending; + const isUploading = uploadPhase === "uploading"; const activeJobs = useMemo( () => @@ -224,97 +129,30 @@ export default function UploadPage() { [uploadedFiles], ); - useEffect(() => { - if (activeJobs.length === 0) { - return; - } - - let cancelled = false; - - const pollJobs = async () => { - const jobStatuses = await Promise.all( - activeJobs.map(async (item) => { - if (!item.job_id) { - return null; - } - - try { - return await getJobStatus(item.job_id); - } catch { - return { - job_id: item.job_id, - status: "failed", - error: "Could not reach the job status endpoint.", - } as JobStatus; - } - }), - ); - - if (cancelled) { - return; - } - - if ( - jobStatuses.some( - (job) => job?.status === "finished" || job?.status === "failed", - ) - ) { - void queryClient.invalidateQueries({ queryKey: ["gallery"] }); - } - - setUploadedFiles((current) => - current.map((item) => { - if (!item.job_id) { - return item; - } - - const job = jobStatuses.find( - (entry) => entry?.job_id === item.job_id, - ); - if (!job) { - return item; - } - - const processingState = getProcessingState(job.status); - return { - ...item, - jobStatus: job.status, - processingState, - processingStage: job.stage, - error: - processingState === "failed" - ? (job.error ?? item.error) - : item.error, - }; - }), - ); - }; - - void pollJobs(); - const intervalId = window.setInterval(() => { - void pollJobs(); - }, 3000); - - return () => { - cancelled = true; - window.clearInterval(intervalId); - }; - }, [activeJobs, queryClient]); - const onDrop = useCallback( - (acceptedFiles: File[]) => { + async (acceptedFiles: File[]) => { if (acceptedFiles.length === 0) { toast.error("No valid images selected"); return; } - - uploadMutation.mutate(acceptedFiles); + beginUpload(); + try { + const data = await uploadImages(acceptedFiles, setUploadProgress); + completeUpload(data); + void queryClient.invalidateQueries({ queryKey: ["gallery"] }); + toast.success( + `Queued ${data.total} file${data.total === 1 ? "" : "s"} for library processing`, + ); + } catch (error) { + failUpload(); + toast.error(extractErrorMessage(error, "Upload failed")); + } }, - [uploadMutation], + [beginUpload, completeUpload, failUpload, queryClient, setUploadProgress], ); const onBulkDrop = useCallback( - (acceptedFiles: File[]) => { + async (acceptedFiles: File[]) => { if (acceptedFiles.length === 0) { toast.error("No archive selected"); return; @@ -326,9 +164,33 @@ export default function UploadPage() { return; } - bulkUploadMutation.mutate(archive); + beginUpload(); + try { + const data = await uploadImagesBulk(archive, setUploadProgress); + completeUpload(data); + void queryClient.invalidateQueries({ queryKey: ["gallery"] }); + const uploadedCount = data.results.filter( + (item) => item.status === "uploaded", + ).length; + const failedResults = data.results.filter( + (item) => item.status === "failed" && item.error, + ); + toast.success( + `Archive accepted (${uploadedCount} new upload${uploadedCount === 1 ? "" : "s"})`, + ); + if (failedResults.length > 0) { + toast.error( + failedResults.length === 1 + ? failedResults[0]?.error + : `${failedResults.length} files failed. ${failedResults[0]?.error}`, + ); + } + } catch (error) { + failUpload(); + toast.error(extractErrorMessage(error, "Bulk upload failed")); + } }, - [bulkUploadMutation], + [beginUpload, completeUpload, failUpload, queryClient, setUploadProgress], ); const { @@ -409,11 +271,11 @@ export default function UploadPage() { trackedUploads.length > 0 ? Math.round( trackedUploads.reduce((total, item) => { - return total + getItemProgress(item); + return total + getUploadItemProgress(item); }, 0) / trackedUploads.length, ) : isUploading - ? (STAGE_PROGRESS.queued ?? 8) + ? uploadProgress : 0; const progressLabel = isUploading @@ -428,14 +290,22 @@ export default function UploadPage() { return (
-
-

- Upload -

-

- Add images to analyze. Search and clustering update as jobs finish. -

-
+
+ + Library + + +

Upload

+ + {runtime.isPending + ? "Checking local processing mode" + : localAiActive + ? "Local AI analysis is active" + : "Photos will import without AI analysis"} + +
@@ -511,9 +381,9 @@ export default function UploadPage() { {(isUploading || activeJobs.length > 0) && (
-
+
- +

{progressLabel} @@ -527,20 +397,6 @@ export default function UploadPage() { {progressPercent}%

- -
-
-
)} 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..a7fcc39f 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -1,273 +1,148 @@ "use client"; -import { Menu, Moon, Sun, X } from "lucide-react"; +import { + Archive, + Copy, + Heart, + Images, + Library, + LockKeyhole, + type LucideIcon, + Map as MapIcon, + ScanSearch, + 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: 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 }, + ], + }, + { + 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); - - let cancelled = false; +function routePath(href: string) { + return href.split("?")[0] ?? href; +} - void getAppConfig() - .then((config) => { - if (!cancelled) { - setIsMockMode(config.ml_mode === "mock"); - } - }) - .catch(() => { - if (!cancelled) { - setIsMockMode(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}/`); +} - return () => { - cancelled = true; - }; - }, []); +type NavBarProps = { + onNavigate?: () => void; + className?: string; + collapsed?: boolean; +}; + +export default function NavBar({ + onNavigate, + className, + collapsed = false, +}: 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 && ( -
- - -
- )} - - {!isMobile && ( - - )} - - ); - return ( - <> -
-
- {navContent()} -
- - -
- - {mounted && - typeof document !== "undefined" && - createPortal( - <> - -
- - - -
- {isMockMode && ( -
-
- Environment Status -
-
-
- - Mock ML Mode Active -
-

- OCR, embeddings, search, and clustering use - mock-backed data. -

-
-
- )} - - -
-
-
- , - document.body, - )} - + {group.label} + +
    + {group.items.map((item) => { + const active = isCurrentRoute(pathname, item.href, search); + const Icon = item.icon; + + return ( +
  • + +
  • + ); + })} +
+
+ ))} + + ); } 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. +

+
+
+ +
+ +
+
+ + {(runtime.data?.unavailable_reason || runtime.data?.restart_required) && ( +

+ {runtime.data.unavailable_reason ?? + "Start a compatible artifact to apply this runtime."} +

+ )} + +
+ +

+ Switch instantly between the modes already installed in this build. +

+ +
+ +
+ + 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/album-asset-picker.tsx b/frontend/src/components/album-asset-picker.tsx new file mode 100644 index 00000000..bd06058c --- /dev/null +++ b/frontend/src/components/album-asset-picker.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Loader2, Plus, Search, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + addAlbumAssets, + getGallery, + type MediaItem, + searchImages, +} from "@/lib/api"; +import { resolveMediaUrl } from "@/lib/media"; +import { useBodyScrollLock } from "@/lib/use-body-scroll-lock"; + +interface AlbumAssetPickerProps { + albumId: number; + existingIds: ReadonlySet; + onClose: () => void; + onAdded: () => void; +} + +export function AlbumAssetPicker({ + albumId, + existingIds, + onClose, + onAdded, +}: AlbumAssetPickerProps) { + const queryClient = useQueryClient(); + const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const dialogRef = useRef(null); + const openerRef = useRef(null); + + useBodyScrollLock(); + + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), 250); + return () => window.clearTimeout(timer); + }, [query]); + + useEffect(() => { + openerRef.current = document.activeElement as HTMLElement | null; + dialogRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + return; + } + if (event.key !== "Tab" || !dialogRef.current) return; + + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + openerRef.current?.focus(); + }; + }, [onClose]); + + const mediaQuery = useQuery({ + queryKey: ["album-asset-picker", debouncedQuery], + queryFn: async (): Promise => { + if (debouncedQuery) { + const response = await searchImages({ + query: debouncedQuery, + limit: 60, + }); + return response.results.map((result) => ({ + ...result.metadata, + id: result.media_id, + })); + } + return (await getGallery({ limit: 60, sortOrder: "newest" })).items; + }, + }); + + const candidates = useMemo( + () => (mediaQuery.data ?? []).filter((item) => !existingIds.has(item.id)), + [existingIds, mediaQuery.data], + ); + + const addMutation = useMutation({ + mutationFn: () => addAlbumAssets(albumId, Array.from(selectedIds)), + onSuccess: (result) => { + void queryClient.invalidateQueries({ queryKey: ["album", albumId] }); + void queryClient.invalidateQueries({ + queryKey: ["album-assets", albumId], + }); + void queryClient.invalidateQueries({ queryKey: ["albums"] }); + toast.success( + `Added ${result.added_count} photo${result.added_count === 1 ? "" : "s"}`, + ); + onAdded(); + onClose(); + }, + onError: () => toast.error("Couldn't add photos to this album"), + }); + + const toggle = (id: number) => { + setSelectedIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+ + + +
+ +
+ +
+ {mediaQuery.isPending ? ( +
+ +
+ ) : candidates.length === 0 ? ( +
+ {debouncedQuery + ? "No matching photos outside this album." + : "Every available photo is already in this album."} +
+ ) : ( +
+ {candidates.map((item) => { + const selected = selectedIds.has(item.id); + const src = resolveMediaUrl( + item.thumbnail_url ?? item.url, + item.minio_key, + item.id, + !item.thumbnail_url, + ); + return ( + + ); + })} +
+ )} +
+ +
+ + {selectedIds.size} selected + + +
+ +
+ ); +} diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx new file mode 100644 index 00000000..2e92327e --- /dev/null +++ b/frontend/src/components/app-shell.tsx @@ -0,0 +1,365 @@ +"use client"; + +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 CSSProperties, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; +import NavBar from "@/components/NavBar"; +import { UniversalSearch } from "@/components/universal-search"; +import { + UploadStatusRing, + useUploadStatus, +} from "@/components/upload-status-indicator"; +import { + applyThemePreference, + readThemePreference, + THEME_CHANGE_EVENT, +} from "@/lib/theme"; + +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/") + ); +} + +export function AppShell({ children }: AppShellProps) { + const pathname = usePathname(); + const router = useRouter(); + const [drawerOpen, setDrawerOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const drawerRef = useRef(null); + const drawerTriggerRef = useRef(null); + const shellless = isShelllessRoute(pathname); + const uploadStatus = useUploadStatus(); + + useEffect(() => { + const apply = () => applyThemePreference(readThemePreference()); + const media = window.matchMedia?.("(prefers-color-scheme: dark)"); + apply(); + media?.addEventListener("change", apply); + window.addEventListener(THEME_CHANGE_EVENT, apply); + try { + setSidebarCollapsed( + localStorage.getItem("find-sidebar-collapsed") === "true", + ); + } catch { + setSidebarCollapsed(false); + } + return () => { + media?.removeEventListener("change", apply); + window.removeEventListener(THEME_CHANGE_EVENT, apply); + }; + }, []); + + useEffect(() => { + if (pathname) setDrawerOpen(false); + }, [pathname]); + + 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 toggleSidebar = () => { + const next = !sidebarCollapsed; + setSidebarCollapsed(next); + try { + localStorage.setItem("find-sidebar-collapsed", String(next)); + } catch { + // Keep the preference for this session when storage is unavailable. + } + }; + + const shellStyle = { + "--sidebar-width": sidebarCollapsed ? "76px" : "256px", + } as CSSProperties; + + return ( +
+
+
+ + + + + + + + +
+ +
+ + + + + + + + {uploadStatus.active ? ( + + ) : ( + + )} + + {uploadStatus.active ? `${uploadStatus.percent}%` : "Upload"} + + + + + + +
+
+ + + + +
+ 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/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 ( +
+

+ Appearance +

+

+ Choose a theme or follow your operating system automatically. +

+
+ Color theme + {OPTIONS.map(({ value: option, label, icon: Icon }) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/asset-viewer.tsx b/frontend/src/components/asset-viewer.tsx index b431abe9..f32e7249 100644 --- a/frontend/src/components/asset-viewer.tsx +++ b/frontend/src/components/asset-viewer.tsx @@ -14,8 +14,19 @@ * (Immich). Original © its authors. Part of Find, distributed under AGPL-3.0. */ +import { + Archive, + ChevronLeft, + ChevronRight, + Heart, + Pause, + Play, + Trash2, + X, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { nextSlideIndex, normalizeIntervalMs } from "@/lib/slideshow"; +import { useBodyScrollLock } from "@/lib/use-body-scroll-lock"; import { buildPreloadPlan, displayUrl, @@ -75,6 +86,8 @@ export function AssetViewer({ const containerRef = useRef(null); const panStateRef = useRef<{ x: number; y: number } | null>(null); + useBodyScrollLock(); + const active = assets[index]; const hasPrev = index > 0; const hasNext = index < assets.length - 1; @@ -266,6 +279,7 @@ export function AssetViewer({ style={{ position: "fixed", inset: 0, + zIndex: 100, background: "rgba(0,0,0,0.95)", overflow: "hidden", touchAction: "none", @@ -277,11 +291,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. */} - ✕ + {onToggleFavorite && ( @@ -314,9 +330,15 @@ export function AssetViewer({ } aria-pressed={favoriteIds?.has(active.id) ?? false} onClick={() => onToggleFavorite(active.id)} + title={ + favoriteIds?.has(active.id) ? "Remove favorite" : "Add favorite" + } + className="icon-button bg-black/55 text-white" style={{ position: "absolute", top: 16, left: 16 }} > - {favoriteIds?.has(active.id) ? "♥" : "♡"} + )} @@ -330,9 +352,11 @@ export function AssetViewer({ onArchive(active.id); onClose(); }} - style={{ position: "absolute", top: 16, left: 56 }} + title="Archive" + className="icon-button bg-black/55 text-white" + style={{ position: "absolute", top: 16, left: 60 }} > - 🗄 + )} @@ -346,9 +370,11 @@ export function AssetViewer({ onTrash(active.id); onClose(); }} - style={{ position: "absolute", top: 16, left: 96 }} + title="Move to trash" + className="icon-button bg-black/55 text-white" + style={{ position: "absolute", top: 16, left: 104 }} > - 🗑 + )} @@ -358,9 +384,11 @@ export function AssetViewer({ aria-label="Previous image" data-testid="viewer-prev" onClick={goPrev} + title="Previous image" + className="icon-button bg-black/55 text-white" style={{ position: "absolute", top: "50%", left: 16 }} > - ‹ + )} {hasNext && ( @@ -369,9 +397,11 @@ export function AssetViewer({ aria-label="Next image" data-testid="viewer-next" onClick={goNext} + title="Next image" + className="icon-button bg-black/55 text-white" style={{ position: "absolute", top: "50%", right: 16 }} > - › + )} @@ -381,9 +411,15 @@ export function AssetViewer({ data-testid="viewer-slideshow-toggle" aria-pressed={isPlaying} onClick={() => setIsPlaying((p) => !p)} + title={isPlaying ? "Pause slideshow" : "Play slideshow"} + className="icon-button bg-black/55 text-white" style={{ position: "absolute", bottom: 16, right: 16 }} > - {isPlaying ? "⏸" : "▶"} + {isPlaying ? ( + + ) : ( + + )} ); diff --git a/frontend/src/components/hardware-accel-settings.tsx b/frontend/src/components/hardware-accel-settings.tsx index a72e168e..f7aec6cc 100644 --- a/frontend/src/components/hardware-accel-settings.tsx +++ b/frontend/src/components/hardware-accel-settings.tsx @@ -1,16 +1,8 @@ "use client"; -/** - * Hardware acceleration settings section. - * - * Renders the Auto/GPU/CPU toggle and reflects the backend's resolved plan - * from `GET /api/config/hardware` — including the non-blocking CPU-fallback - * notice when a GPU was requested but isn't available. This is the UI surface - * of the Phase 5 speed/low-end goal. - */ - import { useQuery } from "@tanstack/react-query"; -import { type AccelMode, getHardwareReport } from "@/lib/api"; +import { Cpu, Gauge, MonitorCog } from "lucide-react"; +import { type AccelMode, getHardwareReport, getRuntimeConfig } from "@/lib/api"; const MODES: { value: AccelMode; label: string; hint: string }[] = [ { @@ -21,74 +13,160 @@ const MODES: { value: AccelMode; label: string; hint: string }[] = [ { value: "gpu", label: "GPU", - hint: "Prefer GPU; automatically fall back to CPU if unavailable.", + hint: "Prefer GPU and fall back safely when it is unavailable.", }, - { value: "cpu", label: "CPU", hint: "Force CPU. Works on any machine." }, + { value: "cpu", label: "CPU", hint: "Force CPU on any supported machine." }, ]; interface HardwareAccelSettingsProps { - /** Current selected mode (controlled). */ value?: AccelMode; onChange?: (mode: AccelMode) => void; + pending?: boolean; } export function HardwareAccelSettings({ value, onChange, + pending = false, }: HardwareAccelSettingsProps) { const { data, isLoading, isError } = useQuery({ queryKey: ["hardware-report"], queryFn: getHardwareReport, }); - + const { data: runtime } = useQuery({ + queryKey: ["runtime-config"], + queryFn: getRuntimeConfig, + }); 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) => { + const gpuNotInstalled = + mode.value === "gpu" && + runtime !== undefined && + runtime.build_profile !== "nvidia" && + runtime.build_profile !== "development"; + + return ( + + ); + })}
- {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/image-preview-modal.tsx b/frontend/src/components/image-preview-modal.tsx index 9f812cbc..97c117af 100644 --- a/frontend/src/components/image-preview-modal.tsx +++ b/frontend/src/components/image-preview-modal.tsx @@ -9,6 +9,7 @@ import { Download, Heart, ImageOff, + Info, Loader2, RotateCcw, Trash2, @@ -16,7 +17,14 @@ import { } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react"; import { toast } from "sonner"; import { type AnalysisStageName, @@ -35,6 +43,7 @@ import { MINIO_URL_STALE_TIME_MS, resolveMediaUrl, } from "@/lib/media"; +import { useBodyScrollLock } from "@/lib/use-body-scroll-lock"; import { formatBytes, formatDate } from "@/lib/utils"; import { StatusIndicator } from "./status-indicator"; @@ -67,6 +76,10 @@ type ImagePreviewModalProps = { onNext?: () => void; hasPrevious?: boolean; hasNext?: boolean; + /** Context actions such as Archive, Restore, or Remove from album. */ + actions?: React.ReactNode; + /** Keep the viewer addressable at /image/:id while it is open. */ + syncUrl?: boolean; }; const ANALYSIS_STAGE_ORDER: AnalysisStageName[] = [ @@ -214,12 +227,45 @@ export function ImagePreviewModal({ onNext, hasPrevious = false, hasNext = false, + actions, + syncUrl = true, }: ImagePreviewModalProps) { const queryClient = useQueryClient(); const [likedOverride, setLikedOverride] = useState(null); const [confirmingDelete, setConfirmingDelete] = useState(false); const [captionCopied, setCaptionCopied] = useState(false); const [ocrCopied, setOcrCopied] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); + const returnUrlRef = useRef(null); + + useBodyScrollLock(); + + useEffect(() => { + if (!syncUrl || typeof window === "undefined") return; + + returnUrlRef.current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + return () => { + if (window.location.pathname.startsWith("/image/")) { + window.history.replaceState( + window.history.state, + "", + returnUrlRef.current ?? "/timeline", + ); + } + }; + }, [syncUrl]); + + useEffect(() => { + if (!syncUrl || !returnUrlRef.current || typeof window === "undefined") { + return; + } + const returnTo = encodeURIComponent(returnUrlRef.current); + window.history.replaceState( + { ...window.history.state, findImageViewer: true }, + "", + `/image/${media.id}?return=${returnTo}`, + ); + }, [media.id, syncUrl]); useEffect(() => { if (!captionCopied) return; @@ -367,24 +413,19 @@ export function ImagePreviewModal({ return (
- + +
+
+ + {downloadUrl && ( + + + + )} +
-
+ ); +} diff --git a/frontend/src/components/map-timeline-panel.tsx b/frontend/src/components/map-timeline-panel.tsx new file mode 100644 index 00000000..746f281d --- /dev/null +++ b/frontend/src/components/map-timeline-panel.tsx @@ -0,0 +1,134 @@ +"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 { resolveMediaUrl } from "@/lib/media"; +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: + 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], + ); + + 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..156940ec --- /dev/null +++ b/frontend/src/components/private-map.tsx @@ -0,0 +1,638 @@ +"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 { resolveMediaUrl } from "@/lib/media"; +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"); +type GeoJsonStyleSource = Extract< + StyleSpecification["sources"][string], + { type: "geojson" } +>; +type LandSourceData = GeoJsonStyleSource["data"]; + +interface RenderedDomMarker { + marker: MapLibreMarker; + element: HTMLButtonElement; +} + +interface LocalLandFeatureCollection { + features: Array<{ + geometry?: { + type?: string; + coordinates?: unknown; + } | null; + }>; +} + +function createLandOverlay( + container: HTMLDivElement, + map: MapLibreMap, + landData: LandSourceData, +): { sync: () => void; remove: () => void } { + const namespace = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(namespace, "svg"); + const path = document.createElementNS(namespace, "path"); + svg.setAttribute("aria-hidden", "true"); + svg.dataset.mapLandFallback = "true"; + Object.assign(svg.style, { + position: "absolute", + inset: "0", + zIndex: "0", + width: "100%", + height: "100%", + overflow: "hidden", + pointerEvents: "none", + }); + path.setAttribute("fill-rule", "evenodd"); + path.setAttribute("stroke-width", "0.75"); + path.setAttribute("vector-effect", "non-scaling-stroke"); + svg.append(path); + container.append(svg); + + const collection = landData as unknown as LocalLandFeatureCollection; + const sync = () => { + const width = container.clientWidth; + const height = container.clientHeight; + svg.setAttribute("viewBox", `0 0 ${width} ${height}`); + const commands: string[] = []; + + const drawRing = (ring: unknown) => { + if (!Array.isArray(ring)) return; + let drewPoint = false; + for (const coordinate of ring) { + if ( + !Array.isArray(coordinate) || + typeof coordinate[0] !== "number" || + typeof coordinate[1] !== "number" + ) { + continue; + } + const point = map.project([ + coordinate[0], + Math.max(-85.0511, Math.min(85.0511, coordinate[1])), + ]); + commands.push( + `${drewPoint ? "L" : "M"}${point.x.toFixed(1)},${point.y.toFixed(1)}`, + ); + drewPoint = true; + } + if (drewPoint) commands.push("Z"); + }; + + for (const feature of collection.features ?? []) { + const geometry = feature.geometry; + if (!geometry || !Array.isArray(geometry.coordinates)) continue; + if (geometry.type === "Polygon") { + for (const ring of geometry.coordinates) drawRing(ring); + } else if (geometry.type === "MultiPolygon") { + for (const polygon of geometry.coordinates) { + if (!Array.isArray(polygon)) continue; + for (const ring of polygon) drawRing(ring); + } + } + } + + const dark = isDarkMap(); + path.setAttribute("d", commands.join(" ")); + path.setAttribute("fill", dark ? "#172a36" : "#f4efe5"); + path.setAttribute("stroke", dark ? "#355262" : "#b9c5c8"); + }; + + sync(); + return { sync, remove: () => svg.remove() }; +} + +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, + landData: LandSourceData = LAND_SOURCE_URL, +): StyleSpecification { + return { + version: 8, + sources: { + "offline-land": { + type: "geojson", + data: landData, + }, + [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; + let landOverlay: ReturnType | null = null; + const rendered = new Map(); + + void Promise.all([ + import("maplibre-gl"), + fetch(LAND_SOURCE_URL, { cache: "force-cache" }).then((response) => { + if (!response.ok) { + throw new Error(`Bundled map returned ${response.status}`); + } + return response.json() as Promise; + }), + ]).then(([maplibre, landData]) => { + if (disposed || !containerRef.current) { + return; + } + + maplibreRef.current = maplibre; + const map = new maplibre.Map({ + container: containerRef.current, + style: buildOfflineMapStyle(markersRef.current, isDarkMap(), landData), + 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", + ); + landOverlay = createLandOverlay(container, map, landData); + + 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 = + resolveMediaUrl(photo.thumbnail_url, null, photo.id, true) ?? + 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); + landOverlay?.sync(); + fitMarkers(map, maplibre, markersRef.current); + syncMarkers(); + }); + map.on("idle", syncMarkers); + map.on("moveend", syncMarkers); + map.on("moveend", () => landOverlay?.sync()); + map.on("zoomend", syncMarkers); + map.on("zoomend", () => landOverlay?.sync()); + + themeObserver = new MutationObserver(() => { + applyMapPalette(map); + landOverlay?.sync(); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme"], + }); + + resizeObserver = new ResizeObserver(() => { + map.resize(); + landOverlay?.sync(); + }); + resizeObserver.observe(container); + }); + + return () => { + disposed = true; + themeObserver?.disconnect(); + resizeObserver?.disconnect(); + landOverlay?.remove(); + 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..fe244569 --- /dev/null +++ b/frontend/src/components/timeline-media-view.tsx @@ -0,0 +1,407 @@ +"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 { resolveMediaUrl } from "@/lib/media"; +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; + +function resolveApiRoute(url: string | null | undefined) { + return url?.startsWith("/api/") ? resolveMediaUrl(url) : url; +} + +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 id = getId(item); + const rawThumbnailUrl = getThumbnailUrl(item); + const thumbnailUrl = resolveApiRoute(rawThumbnailUrl) ?? ""; + const rawOriginalUrl = getOriginalUrl(item); + return { + id, + thumbnailUrl, + 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, + }; + }), + [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 rawThumbnailUrl = getThumbnailUrl(item); + const thumbnailUrl = resolveApiRoute(rawThumbnailUrl); + return ( +
+ + {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/trash-retention-settings.tsx b/frontend/src/components/trash-retention-settings.tsx new file mode 100644 index 00000000..589f5c7e --- /dev/null +++ b/frontend/src/components/trash-retention-settings.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Clock3, Info } from "lucide-react"; + +interface TrashRetentionSettingsProps { + value: number; + pending: boolean; + onChange: (days: number) => void; +} + +const PRESETS = [7, 30, 90, 0] as const; + +export function TrashRetentionSettings({ + value, + pending, + onChange, +}: TrashRetentionSettingsProps) { + return ( +
+
+
+ +
+
+

+ Trash retention +

+ +
+

+ Permanently delete trashed photos after a chosen delay. +

+
+
+
+ +
+ {PRESETS.map((days) => ( + + ))} + +
+
+ ); +} diff --git a/frontend/src/components/universal-search.tsx b/frontend/src/components/universal-search.tsx new file mode 100644 index 00000000..aa968802 --- /dev/null +++ b/frontend/src/components/universal-search.tsx @@ -0,0 +1,239 @@ +"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 inputRef = 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); + }} + > +
{ + event.preventDefault(); + if (normalized) + navigate(`/search?q=${encodeURIComponent(query.trim())}`); + }} + > +
+ ); +} diff --git a/frontend/src/components/upload-status-indicator.tsx b/frontend/src/components/upload-status-indicator.tsx new file mode 100644 index 00000000..02008bcd --- /dev/null +++ b/frontend/src/components/upload-status-indicator.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { AlertCircle } from "lucide-react"; +import { useEffect, useMemo } from "react"; +import { getJobStatus } from "@/lib/api"; +import { + getUploadItemProgress, + useUploadQueueStore, +} from "@/store/uploadQueueStore"; + +export function UploadQueueMonitor() { + const queryClient = useQueryClient(); + const items = useUploadQueueStore((state) => state.items); + const phase = useUploadQueueStore((state) => state.phase); + const updateJob = useUploadQueueStore((state) => state.updateJob); + const clearCompleted = useUploadQueueStore((state) => state.clearCompleted); + const activeJobs = useMemo( + () => + items.filter( + (item) => + item.job_id && + item.status === "uploaded" && + item.processingState !== "indexed" && + item.processingState !== "failed", + ), + [items], + ); + + useEffect(() => { + if (activeJobs.length === 0) return; + let cancelled = false; + const poll = async () => { + const settled = await Promise.allSettled( + activeJobs.map((item) => getJobStatus(item.job_id ?? "")), + ); + if (cancelled) return; + let terminalUpdate = false; + for (const result of settled) { + if (result.status !== "fulfilled") continue; + updateJob(result.value); + terminalUpdate ||= + result.value.status === "finished" || + result.value.status === "failed"; + } + if (terminalUpdate) { + void queryClient.invalidateQueries({ queryKey: ["gallery"] }); + void queryClient.invalidateQueries({ queryKey: ["gallery-infinite"] }); + } + }; + void poll(); + const interval = window.setInterval(() => void poll(), 2500); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [activeJobs, queryClient, updateJob]); + + useEffect(() => { + if (phase !== "idle" || items.length === 0 || activeJobs.length > 0) return; + if ( + items.some( + (item) => item.status === "failed" || item.processingState === "failed", + ) + ) { + return; + } + const timer = window.setTimeout(clearCompleted, 1800); + return () => window.clearTimeout(timer); + }, [activeJobs.length, clearCompleted, items, phase]); + + return null; +} + +export function useUploadStatus() { + const phase = useUploadQueueStore((state) => state.phase); + const uploadProgress = useUploadQueueStore((state) => state.uploadProgress); + const items = useUploadQueueStore((state) => state.items); + const tracked = items.filter((item) => item.status === "uploaded"); + const failed = items.some( + (item) => item.status === "failed" || item.processingState === "failed", + ); + const active = tracked.filter( + (item) => + item.processingState !== "indexed" && item.processingState !== "failed", + ); + const percent = + phase === "uploading" + ? uploadProgress + : tracked.length > 0 + ? Math.round( + tracked.reduce( + (total, item) => total + getUploadItemProgress(item), + 0, + ) / tracked.length, + ) + : failed + ? 100 + : 0; + return { + active: phase === "uploading" || active.length > 0 || failed, + activeCount: + phase === "uploading" ? Math.max(1, active.length) : active.length, + failed, + percent, + phase, + }; +} + +export function UploadStatusRing({ size = 24 }: { size?: number }) { + const status = useUploadStatus(); + if (!status.active) return null; + const radius = 9; + const circumference = 2 * Math.PI * radius; + const offset = circumference * (1 - status.percent / 100); + + return ( + + + {status.failed ? : null} + + ); +} diff --git a/frontend/src/components/vault/VaultGallery.tsx b/frontend/src/components/vault/VaultGallery.tsx index f79552b2..d00967ae 100644 --- a/frontend/src/components/vault/VaultGallery.tsx +++ b/frontend/src/components/vault/VaultGallery.tsx @@ -1,138 +1,380 @@ "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, + KeyRound, + Loader2, + Lock, + RotateCcw, + Settings2, +} 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 { + changeVaultPassword, + 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 [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>( + {}, + ); 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 loadStreams = async () => { - const nextUrls: Record = {}; + 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 }); - 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 private 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(() => revokeThumbnails, [revokeThumbnails]); + + const restoreMutation = useMutation({ + mutationFn: async (mediaId: number) => { + 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); + delete objectUrlsRef.current[mediaId]; + setThumbnailUrls({ ...objectUrlsRef.current }); + } + setSessionMessage("Image restored to your timeline."); + await queryClient.invalidateQueries({ queryKey: VAULT_QUERY_KEY }); + }, + 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 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(() => { + 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.", + ); + } + }); + } + }, [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 () => { - Object.values(objectUrlsRef.current).forEach((url) => { - URL.revokeObjectURL(url); - }); - objectUrlsRef.current = {}; + 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 private 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,29 +391,146 @@ export function VaultGallery() { return (
-
-
+
+
-

- Hidden Vault -

-

- Encrypted images unlocked for this session only. +

+ Locked Vault +

+

+ Photos remain in private storage behind this local lock. The + session token exists in memory only.

+
+ + + Vault security + +
+
+ Automatic lock +
+ {(["immediate", "delay", "idle"] as const).map((mode) => ( + + ))} +
+ +
+
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + const current = String(data.get("current") ?? ""); + const next = String(data.get("next") ?? ""); + const confirmation = String(data.get("confirmation") ?? ""); + if (next !== confirmation) { + setSessionMessage("New vault passwords do not match."); + return; + } + passwordMutation.mutate({ current, next }); + }} + > +

Change password

+
+ + + + +
+
+
+ {newRecoveryCode && ( +
+

+ New one-time recovery code +

+ + {newRecoveryCode} + +
+ )} +
+ {sessionMessage && ( -

{sessionMessage}

+

+ {sessionMessage} +

)} {listQuery.isLoading && ( @@ -180,68 +539,63 @@ export function VaultGallery() {
)} - {listQuery.isError && !sessionMessage && ( + {listQuery.isError && !isExpiredVaultSession(listQuery.error) && (

Failed to load vault

)} - {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. - {item.filename} - ) : ( -
- - 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) => ( + + )} + renderViewer={(props) => ( + + )} + /> )}
diff --git a/frontend/src/components/vault/VaultUnlock.tsx b/frontend/src/components/vault/VaultUnlock.tsx index 454a35e9..2845a918 100644 --- a/frontend/src/components/vault/VaultUnlock.tsx +++ b/frontend/src/components/vault/VaultUnlock.tsx @@ -1,91 +1,212 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import axios from "axios"; +import { KeyRound, LockKeyhole, ShieldCheck } from "lucide-react"; import { useState } from "react"; -import { api } from "@/lib/api"; import { vaultStore } from "@/store/vaultStore"; +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)) { - return; - } - - const passphrase = input.value.trim(); - if (!passphrase) { - setErrorMessage("Enter a vault passphrase to continue."); - input.focus(); + 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; } - - setErrorMessage(null); - setIsSubmitting(true); - + setBusy(true); + setError(null); try { - const response = await api.post<{ session_token: string }>( - "/api/vault/unlock", - { - passphrase, - }, - ); - vaultStore.getState().unlock(response.data.session_token); - } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 401) { - setErrorMessage("Incorrect 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 (status.isError) + return ( +

+ Could not determine vault status. Please retry. +

+ ); + + if (recovery) { + return ( +
+ +

Save your recovery code

+

+ This code is shown once. Keep it offline; Find stores only a one-way + hash. +

+ + {recovery.code} + + +
+ ); + } + return (
- -

- First time here? Enter a new passphrase to create your vault. After - that, use the same passphrase every time you unlock it. -

+ + + +
+

+ {!initialized + ? "Create your private vault" + : mode === "recover" + ? "Recover your vault" + : "Unlock vault"} +

+

+ {!initialized + ? "Set a password to keep hidden photos behind a separate local lock. Image bytes remain in your private storage and are not encrypted." + : mode === "recover" + ? "Enter the recovery code you saved and choose a new password." + : "Enter your vault password. It is never stored or sent outside this Find instance."} +

+
+ {mode === "recover" && ( + + )} { - if (errorMessage) { - setErrorMessage(null); - } - }} - className="rounded-2xl border border-[var(--frost)] bg-[color:var(--surface-soft)] px-4 py-3 text-sm text-[color:var(--near-white)] outline-none transition focus:border-[color:var(--blue)]" - placeholder="Enter or create vault passphrase" + autoComplete={ + initialized && mode === "unlock" ? "current-password" : "new-password" + } + placeholder={ + !initialized || mode === "recover" + ? "New password (8+ characters)" + : "Vault password" + } + className="rounded-xl border border-[var(--frost)] bg-[color:var(--surface-soft)] px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-[color:var(--blue)]" /> + {!initialized && ( + + )} - {errorMessage &&

{errorMessage}

} + {initialized && + (mode === "recover" || status.data?.recovery_available) && ( + + )} + {error && ( +

+ {error} +

+ )}
); } diff --git a/frontend/src/components/vault/vault-client.ts b/frontend/src/components/vault/vault-client.ts new file mode 100644 index 00000000..8201c3b4 --- /dev/null +++ b/frontend/src/components/vault/vault-client.ts @@ -0,0 +1,127 @@ +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 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 { + 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..e7988c31 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,107 @@ export interface JobStatus { } export interface AppConfig { - ml_mode: "full" | "mock"; - 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 { + 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 +343,39 @@ 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[]; + /** 0 disables automatic age-based deletion. */ + trash_retention_days: number; +} + +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,9 +388,53 @@ 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[], + onProgress?: (progress: number) => void, ): Promise => { const formData = new FormData(); @@ -274,6 +447,10 @@ export const uploadImages = async ( headers: { "Content-Type": "multipart/form-data", }, + onUploadProgress: (event) => { + if (event.total) + onProgress?.(Math.round((event.loaded / event.total) * 100)); + }, }); return response.data; @@ -281,6 +458,7 @@ export const uploadImages = async ( export const uploadImagesBulk = async ( zipFile: File, + onProgress?: (progress: number) => void, ): Promise => { const formData = new FormData(); formData.append("file", zipFile); @@ -292,6 +470,10 @@ export const uploadImagesBulk = async ( headers: { "Content-Type": "multipart/form-data", }, + onUploadProgress: (event) => { + if (event.total) + onProgress?.(Math.round((event.loaded / event.total) * 100)); + }, }, ); 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/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 }), + ); +} diff --git a/frontend/src/lib/use-ai-availability.ts b/frontend/src/lib/use-ai-availability.ts new file mode 100644 index 00000000..8f92eed3 --- /dev/null +++ b/frontend/src/lib/use-ai-availability.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { getRuntimeConfig } from "@/lib/api"; + +export function useAiAvailability() { + const runtime = useQuery({ + queryKey: ["runtime-config"], + queryFn: getRuntimeConfig, + }); + const aiUnavailable = runtime.data ? !runtime.data.ai_enabled : false; + return { + aiUnavailable, + unavailableMessage: aiUnavailable + ? "Local AI is disabled in this installed build. Enable an AI profile in Settings." + : null, + runtime, + }; +} diff --git a/frontend/src/lib/use-body-scroll-lock.ts b/frontend/src/lib/use-body-scroll-lock.ts new file mode 100644 index 00000000..312af709 --- /dev/null +++ b/frontend/src/lib/use-body-scroll-lock.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useEffect } from "react"; + +let activeLocks = 0; +let previousOverflow = ""; + +/** Keeps the document stationary while one or more overlays are mounted. */ +export function useBodyScrollLock() { + useEffect(() => { + if (activeLocks === 0) { + previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + } + activeLocks += 1; + + return () => { + activeLocks = Math.max(0, activeLocks - 1); + if (activeLocks === 0) { + document.body.style.overflow = previousOverflow; + } + }; + }, []); +} 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; } diff --git a/frontend/src/store/uploadQueueStore.ts b/frontend/src/store/uploadQueueStore.ts new file mode 100644 index 00000000..b2ce24c6 --- /dev/null +++ b/frontend/src/store/uploadQueueStore.ts @@ -0,0 +1,130 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import type { JobStatus, UploadResponse, UploadResult } from "@/lib/api"; + +export type UploadProcessingState = + | "queued" + | "processing" + | "indexed" + | "failed"; + +export type UploadQueueItem = UploadResult & { + jobStatus?: JobStatus["status"]; + processingState?: UploadProcessingState; + processingStage?: string; +}; + +type UploadQueueState = { + phase: "idle" | "uploading"; + uploadProgress: number; + items: UploadQueueItem[]; + beginUpload: () => void; + setUploadProgress: (progress: number) => void; + completeUpload: (response: UploadResponse) => void; + failUpload: () => void; + updateJob: (job: JobStatus) => void; + clearCompleted: () => void; + clearAll: () => void; +}; + +export const STAGE_PROGRESS: Record = { + queued: 8, + started: 16, + processing: 20, + "loading image": 22, + "extracting exif": 34, + "generating mock metadata": 48, + "detecting objects": 48, + "generating caption": 62, + "running ocr": 74, + "generating embedding": 88, + "indexing complete": 96, + "detecting faces": 96, + "clustering queued": 98, + indexed: 100, + failed: 100, +}; + +export function getProcessingState( + status?: JobStatus["status"], +): UploadProcessingState { + if (status === "finished") return "indexed"; + if (status === "failed") return "failed"; + if (status === "started") return "processing"; + return "queued"; +} + +export function getUploadItemProgress(item: UploadQueueItem): number { + if (item.status === "failed" || item.processingState === "failed") return 100; + if (item.processingState === "indexed") return 100; + const normalizedStage = item.processingStage?.trim().toLowerCase(); + if (normalizedStage && STAGE_PROGRESS[normalizedStage] !== undefined) { + return STAGE_PROGRESS[normalizedStage] ?? 0; + } + if (item.processingState === "processing" || item.jobStatus === "started") { + return STAGE_PROGRESS.processing ?? 20; + } + if (item.processingState === "queued" || item.jobStatus === "queued") { + return STAGE_PROGRESS.queued ?? 8; + } + return 0; +} + +function hydrateResults(response: UploadResponse): UploadQueueItem[] { + return response.results.map((result) => ({ + ...result, + jobStatus: result.status === "uploaded" ? "queued" : undefined, + processingState: result.status === "uploaded" ? "queued" : undefined, + })); +} + +export const useUploadQueueStore = create()( + persist( + (set) => ({ + phase: "idle", + uploadProgress: 0, + items: [], + beginUpload: () => set({ phase: "uploading", uploadProgress: 1 }), + setUploadProgress: (progress) => + set({ uploadProgress: Math.max(1, Math.min(99, progress)) }), + completeUpload: (response) => + set((state) => ({ + phase: "idle", + uploadProgress: 100, + items: [...hydrateResults(response), ...state.items], + })), + failUpload: () => set({ phase: "idle", uploadProgress: 0 }), + updateJob: (job) => + set((state) => ({ + items: state.items.map((item) => + item.job_id === job.job_id + ? { + ...item, + jobStatus: job.status, + processingState: getProcessingState(job.status), + processingStage: job.stage, + error: + job.status === "failed" + ? (job.error ?? item.error) + : item.error, + } + : item, + ), + })), + clearCompleted: () => + set((state) => ({ + items: state.items.filter( + (item) => + item.status === "failed" || item.processingState === "failed", + ), + uploadProgress: 0, + })), + clearAll: () => set({ items: [], phase: "idle", uploadProgress: 0 }), + }), + { + name: "find-upload-queue-v1", + storage: createJSONStorage(() => localStorage), + partialize: (state) => ({ items: state.items }), + }, + ), +); diff --git a/plan.md b/plan.md index 9ba0a814..a0007219 100644 --- a/plan.md +++ b/plan.md @@ -1,8 +1,114 @@ # 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:** `canary` (the protected default integration branch). > **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-15 repository and release governance addendum + +- [x] Feature/fix PRs target the protected `canary` default branch; `main` + accepts only a reviewed promotion from canary. +- [x] Patch, minor, and major release preparation share one manually dispatched + workflow that synchronizes every application and lockfile version. +- [x] A main promotion starts a restartable three-hour release quiet period; + the emergency path is reserved for reviewed critical patch releases. +- [x] CI, dependency updates, CODEOWNERS, issue/PR templates, image provenance, + and release automation are maintained as protected repository policy. + +## 2026-07-15 v1.1.3 patch addendum + +- [x] The NVIDIA artifact uses a CUDA 12/cuDNN 9 ONNX Runtime compatible with + its locked PyTorch CUDA 12.8 runtime rather than falling back to CPU while + looking for `libcublasLt.so.11`. +- [x] InsightFace preloads the CUDA libraries bundled with the NVIDIA Python + packages before opening ONNX sessions; CPU-only builds remain CUDA-free. +- [x] Version metadata is aligned at `1.1.3` across web, API, and desktop. +- [x] Verification passes: 60 focused backend ML/runtime/worker tests, 257 + frontend tests, frontend production and Tauri builds, modular CPU/NVIDIA + dependency exports, and live CUDA sessions for all five AntelopeV2 models. + +## 2026-07-14 v1.1.2 completion addendum + +- [x] Timeline browsing has one scroll model: native scrollbars are hidden, + the chronological scrubber appears only for overflowing multi-period + libraries, and a one-photo library has no inert scroll control. +- [x] Image previews lock background scrolling, use addressable `/image/{id}` + URLs, include metadata and navigation, and expose route-appropriate archive, + trash, restore, and album-removal actions. +- [x] Album sharing is disclosure-based and album detail can add photos from + recent media or semantic search without leaving the album. +- [x] Trash retention supports 7, 30, 90, custom-day, and never-delete modes; + expired items are purged automatically when Trash is opened. +- [x] Search, upload, account, duplicates, clusters, people, and Settings use a + compact hierarchy; duplicate behavior and AI-disabled behavior are explicit. +- [x] GPU controls are unavailable when the installed modular artifact has no + GPU runtime. No-AI builds keep import and organization functional without + presenting clustering controls as usable. +- [x] The private MapLibre map visibly renders bundled Natural Earth geometry + even when browser worker rendering omits GeoJSON fills; its main-thread SVG + fallback remains local and follows map pan, zoom, resize, and theme changes. +- [x] Version metadata is aligned at `1.1.2` across web, API, and desktop. +- [x] Verification passes: frontend 253 tests, production build with 20 routes, + backend Ruff checks, and backend 517 passed / 5 skipped. + +## 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 +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.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. @@ -148,7 +254,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 +279,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 +395,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,13 +469,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** — 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). +- [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 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. +- [~] **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. @@ -409,8 +519,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 diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 00000000..3d2c0b1d --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Keep Find's backend, web, desktop, UI, lock, and changelog versions aligned.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import os +import re +from pathlib import Path + +VERSION_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +def parse_version(value: str) -> tuple[int, int, int]: + match = VERSION_RE.fullmatch(value) + if not match: + raise ValueError(f"Invalid semantic version: {value}") + return tuple(int(part) for part in match.groups()) # type: ignore[return-value] + + +def bump_version(current: str, level: str) -> str: + major, minor, patch = parse_version(current) + if level == "major": + return f"{major + 1}.0.0" + if level == "minor": + return f"{major}.{minor + 1}.0" + if level == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"Unsupported bump level: {level}") + + +def _replace_once(path: Path, pattern: str, replacement: str) -> None: + content = path.read_text(encoding="utf-8") + updated, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE) + if count != 1: + raise RuntimeError(f"Expected one version match in {path}, found {count}") + path.write_text(updated, encoding="utf-8", newline="\n") + + +def read_versions(root: Path) -> dict[str, str]: + files = { + "backend": (root / "backend/pyproject.toml", r'^version = "([^"]+)"'), + "api": (root / "backend/src/find_api/__init__.py", r'^__version__ = "([^"]+)"'), + "web": (root / "frontend/package.json", r'^ "version": "([^"]+)"'), + "desktop": (root / "frontend/src-tauri/Cargo.toml", r'^version = "([^"]+)"'), + "tauri": ( + root / "frontend/src-tauri/tauri.conf.json", + r'^ "version": "([^"]+)"', + ), + "ui": (root / "frontend/src/components/app-shell.tsx", r">v(\d+\.\d+\.\d+)<"), + } + versions: dict[str, str] = {} + for name, (path, pattern) in files.items(): + match = re.search(pattern, path.read_text(encoding="utf-8"), re.MULTILINE) + if not match: + raise RuntimeError(f"Version not found in {path}") + versions[name] = match.group(1) + return versions + + +def current_version(root: Path) -> str: + versions = read_versions(root) + unique = set(versions.values()) + if len(unique) != 1: + rendered = ", ".join(f"{name}={version}" for name, version in versions.items()) + raise RuntimeError(f"Version metadata is inconsistent: {rendered}") + version = unique.pop() + parse_version(version) + return version + + +def update_versions( + root: Path, + level: str, + *, + notes: list[str] | None = None, + release_date: dt.date | None = None, +) -> str: + old = current_version(root) + new = bump_version(old, level) + + replacements = [ + (root / "backend/pyproject.toml", r'^version = "[^"]+"', f'version = "{new}"'), + ( + root / "backend/src/find_api/__init__.py", + r'^__version__ = "[^"]+"', + f'__version__ = "{new}"', + ), + ( + root / "frontend/package.json", + r'^ "version": "[^"]+"', + f' "version": "{new}"', + ), + ( + root / "frontend/src-tauri/Cargo.toml", + r'^version = "[^"]+"', + f'version = "{new}"', + ), + ( + root / "frontend/src-tauri/tauri.conf.json", + r'^ "version": "[^"]+"', + f' "version": "{new}"', + ), + ( + root / "frontend/src/components/app-shell.tsx", + r">v\d+\.\d+\.\d+<", + f">v{new}<", + ), + ( + root / "backend/uv.lock", + r'(\[\[package\]\]\nname = "find-backend"\nversion = ")[^"]+("\n)', + rf"\g<1>{new}\2", + ), + ( + root / "frontend/src-tauri/Cargo.lock", + r'(\[\[package\]\]\nname = "find-desktop"\nversion = ")[^"]+("\n)', + rf"\g<1>{new}\2", + ), + ] + for path, pattern, replacement in replacements: + _replace_once(path, pattern, replacement) + + changelog = root / "CHANGELOG.md" + content = changelog.read_text(encoding="utf-8") + release_date = release_date or dt.date.today() + note_lines = notes or ["Release notes will be finalized during maintainer review."] + bullets = "\n".join( + line if line.lstrip().startswith("-") else f"- {line}" + for line in note_lines + if line.strip() + ) + section = f"## [{new}] — {release_date.isoformat()}\n\n### Changed\n\n{bullets}\n\n" + marker = "distributed under AGPL-3.0 (see `LICENSE` / `NOTICE`).\n\n" + if marker not in content: + raise RuntimeError("Changelog insertion marker not found") + changelog.write_text( + content.replace(marker, marker + section, 1), encoding="utf-8", newline="\n" + ) + + if current_version(root) != new: + raise RuntimeError("Version update did not converge") + return new + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("level", nargs="?", choices=("patch", "minor", "major")) + parser.add_argument( + "--check", action="store_true", help="Only validate version consistency" + ) + parser.add_argument( + "--root", type=Path, default=Path(__file__).resolve().parents[1] + ) + parser.add_argument("--notes-file", type=Path) + args = parser.parse_args() + + root = args.root.resolve() + if args.check: + version = current_version(root) + else: + if not args.level: + parser.error("level is required unless --check is used") + notes = None + if args.notes_file and args.notes_file.exists(): + notes = [ + line.strip() + for line in args.notes_file.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + version = update_versions(root, args.level, notes=notes) + + print(version) + output = os.environ.get("GITHUB_OUTPUT") + if output: + with Path(output).open("a", encoding="utf-8", newline="\n") as handle: + handle.write(f"version={version}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())