> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gumnut.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Search assets

> Searches for assets by content, by typed structured filters on albums, people, date range, and location, or both. Content searches are ranked by relevance; filter-only searches return matches newest-first. Use this tool when the user describes *what's in* the photos they want — subjects, scenes, places, activities, moods, objects — optionally narrowed by album, person, date, or location.

Prefer typed filters for anything the request states exactly: `album_id` for album membership, `person_ids` for people, `local_datetime_before`/`local_datetime_after` for date ranges, and `center` + `radius` or `bbox` for location. There is no typed camera or place-name filter — pass those terms in the free-text `query`; the metadata full-text stage can match those terms, while dense retrieval adds visual-semantic matches. For example, 'photos of my kids at the beach last summer' becomes `query='kids at the beach'` + `local_datetime_after=2025-06-01` + `local_datetime_before=2025-09-01`.

**Use `list_assets` instead** for a plain structured browse that album, person, date-range, location, or asset-ID filters can answer with no content `query` — it's cheaper and more deterministic than semantic search.

**Location filtering is by coordinate,** in two mutually-exclusive modes: a radius (`center` + `radius`) or a bounding box (`bbox`).

At least one of `query`, `album_id`, `person_ids`, `local_datetime_before`, or `local_datetime_after` must be provided; a location filter only narrows those results and is not a search criterion on its own.



## OpenAPI

````yaml https://api.gumnut.ai/openapi.json get /api/search
openapi: 3.1.0
info:
  title: Gumnut API
  description: >-
    The Gumnut API manages photo and video libraries: upload and organize
    assets, search by content, group detected faces into people, and curate
    albums and stacks.


    Headless clients authenticate by sending either a Gumnut API key
    (`apikey_...`) or an OAuth access token as a Bearer token in the
    `Authorization` header. The Gumnut web and mobile apps sign in with a
    first-party session (Clerk session JWT) instead, which is also the only
    credential that can manage API keys.
  contact:
    name: Gumnut
    url: https://www.gumnut.ai/contact
  version: 1.0.0
servers:
  - url: https://api.gumnut.ai
    description: Production
security:
  - apiKeyAuth: []
  - oauthJwtAuth: []
tags:
  - name: assets
    description: >-
      Photos and videos in a library: upload, list and filter, update metadata,
      trash and restore.
  - name: search
    description: >-
      Content-based search over a library's assets, with the same filters as
      asset listing.
  - name: albums
    description: User-curated collections of assets.
  - name: album-assets
    description: Link records connecting albums to their member assets.
  - name: stacks
    description: >-
      Groups of related shots of the same moment, presented as a single unit
      with a cover asset.
  - name: people
    description: Named people, each built from clustered faces.
  - name: faces
    description: Detected faces and their assignment to people.
  - name: libraries
    description: Top-level containers that own assets, albums, people, and everything else.
  - name: events
    description: Change-event feed for keeping client state in sync.
  - name: tasks
    description: Status of background processing tasks.
  - name: users
    description: The authenticated user's profile.
  - name: api-keys
    description: >-
      Create and manage Gumnut API keys. Requires a first-party Gumnut app
      session — API keys and OAuth tokens cannot manage credentials.
  - name: oauth
    description: OAuth flow endpoints for obtaining and refreshing access tokens.
  - name: server
    description: Service health.
paths:
  /api/search:
    get:
      tags:
        - search
      summary: Search assets
      description: >-
        Searches for assets by content, by typed structured filters on albums,
        people, date range, and location, or both. Content searches are ranked
        by relevance; filter-only searches return matches newest-first. Use this
        tool when the user describes *what's in* the photos they want —
        subjects, scenes, places, activities, moods, objects — optionally
        narrowed by album, person, date, or location.


        Prefer typed filters for anything the request states exactly: `album_id`
        for album membership, `person_ids` for people,
        `local_datetime_before`/`local_datetime_after` for date ranges, and
        `center` + `radius` or `bbox` for location. There is no typed camera or
        place-name filter — pass those terms in the free-text `query`; the
        metadata full-text stage can match those terms, while dense retrieval
        adds visual-semantic matches. For example, 'photos of my kids at the
        beach last summer' becomes `query='kids at the beach'` +
        `local_datetime_after=2025-06-01` + `local_datetime_before=2025-09-01`.


        **Use `list_assets` instead** for a plain structured browse that album,
        person, date-range, location, or asset-ID filters can answer with no
        content `query` — it's cheaper and more deterministic than semantic
        search.


        **Location filtering is by coordinate,** in two mutually-exclusive
        modes: a radius (`center` + `radius`) or a bounding box (`bbox`).


        At least one of `query`, `album_id`, `person_ids`,
        `local_datetime_before`, or `local_datetime_after` must be provided; a
        location filter only narrows those results and is not a search criterion
        on its own.
      operationId: search_assets
      parameters:
        - name: library_id
          in: query
          required: false
          schema:
            anyOf:
              - type: string
              - type: 'null'
            title: Library Id
            description: >-
              Library to search. Optional if the user has a single live
              (non-trashed) library; required when they have multiple.
          description: >-
            Library to search. Optional if the user has a single live
            (non-trashed) library; required when they have multiple.
        - name: query
          in: query
          required: false
          schema:
            anyOf:
              - type: string
                maxLength: 1000
              - type: 'null'
            description: >-
              Natural-language search text. It runs independently through dense
              visual retrieval and authoritative-metadata full-text retrieval,
              then the ranked lists are fused. Concrete visual concepts work
              well in the dense stage, while exact metadata terms can match
              through full-text search.


              Resolve album and people names to IDs and pass them as `album_id`
              and `person_ids`; convert date phrases like 'in 2023' into ISO
              8601 bounds on `local_datetime_after`/`local_datetime_before`
              (here, `2023-01-01` and `2024-01-01`). None of those belong in
              `query`.
            title: Query
          description: >-
            Natural-language search text. It runs independently through dense
            visual retrieval and authoritative-metadata full-text retrieval,
            then the ranked lists are fused. Concrete visual concepts work well
            in the dense stage, while exact metadata terms can match through
            full-text search.


            Resolve album and people names to IDs and pass them as `album_id`
            and `person_ids`; convert date phrases like 'in 2023' into ISO 8601
            bounds on `local_datetime_after`/`local_datetime_before` (here,
            `2023-01-01` and `2024-01-01`). None of those belong in `query`.
        - name: person_ids
          in: query
          required: false
          schema:
            anyOf:
              - items:
                  type: string
                type: array
              - type: 'null'
            description: >-
              Filter to assets containing ALL of these person IDs (intersection,
              not union). Accepts multiple `person_ids=` query params or a
              single comma-delimited value (e.g., `person_123,person_abc`).
              Person IDs are carried by the entries of an asset's `people` field
              (returned with `include=people`).
            title: Person Ids
          description: >-
            Filter to assets containing ALL of these person IDs (intersection,
            not union). Accepts multiple `person_ids=` query params or a single
            comma-delimited value (e.g., `person_123,person_abc`). Person IDs
            are carried by the entries of an asset's `people` field (returned
            with `include=people`).
        - name: album_id
          in: query
          required: false
          schema:
            anyOf:
              - type: string
              - type: 'null'
            title: Album Id
            description: >-
              Return only assets in this album — the album's `album_` ID, not
              its name.
          description: >-
            Return only assets in this album — the album's `album_` ID, not its
            name.
        - name: local_datetime_after
          in: query
          required: false
          schema:
            anyOf:
              - type: string
                format: date-time
              - type: 'null'
            title: Local Datetime After
            description: >-
              Only include assets captured strictly after this instant (ISO
              8601; exclusive). Convert a relative or natural-language date
              phrase ('in 2023') into an explicit bound before sending.
              `local_datetime` is the photo's wall-clock time in the device's
              own timezone. Naive values compare directly against
              `local_datetime`. Timezone-aware values: assets with a known
              offset are compared in UTC (`local_datetime - offset`); assets
              without an offset fall back to wall-clock comparison against
              `local_datetime`.
          description: >-
            Only include assets captured strictly after this instant (ISO 8601;
            exclusive). Convert a relative or natural-language date phrase ('in
            2023') into an explicit bound before sending. `local_datetime` is
            the photo's wall-clock time in the device's own timezone. Naive
            values compare directly against `local_datetime`. Timezone-aware
            values: assets with a known offset are compared in UTC
            (`local_datetime - offset`); assets without an offset fall back to
            wall-clock comparison against `local_datetime`.
        - name: local_datetime_before
          in: query
          required: false
          schema:
            anyOf:
              - type: string
                format: date-time
              - type: 'null'
            title: Local Datetime Before
            description: >-
              Only include assets captured strictly before this instant (ISO
              8601; exclusive). Same conversion requirement and awareness/offset
              semantics as `local_datetime_after`.
          description: >-
            Only include assets captured strictly before this instant (ISO 8601;
            exclusive). Same conversion requirement and awareness/offset
            semantics as `local_datetime_after`.
        - name: center
          in: query
          required: false
          schema:
            anyOf:
              - type: string
              - type: 'null'
            title: Center
            description: >-
              Center point of a radius location filter: two comma-separated
              decimal-degree numbers `longitude,latitude`, e.g. `-77.05,38.95`.
              Supply with `radius`.
          description: >-
            Center point of a radius location filter: two comma-separated
            decimal-degree numbers `longitude,latitude`, e.g. `-77.05,38.95`.
            Supply with `radius`.
        - name: radius
          in: query
          required: false
          schema:
            anyOf:
              - type: number
              - type: 'null'
            title: Radius
            description: >-
              Radius of the `center` location filter, in meters (greater than 0,
              at most 50,000).
          description: >-
            Radius of the `center` location filter, in meters (greater than 0,
            at most 50,000).
        - name: bbox
          in: query
          required: false
          schema:
            anyOf:
              - type: string
              - type: 'null'
            title: Bbox
            description: >-
              Bounding-box (map viewport) location filter: four comma-separated
              decimal-degree numbers
              `min_longitude,min_latitude,max_longitude,max_latitude`
              (west,south,east,north), e.g. `-77.1,38.9,-77.0,39.0`. A box whose
              `min_longitude` exceeds `max_longitude` crosses the antimeridian:
              it selects the band running east from `min_longitude` over ±180°
              to `max_longitude`, so there is no need to split it client-side.
              Longitude order is therefore significant — transposed corners read
              as a crossing viewport, not as an error. A viewport 360° or wider
              must be sent as the full range `-180,...,180,...`, which the
              wrapped form cannot express.
          description: >-
            Bounding-box (map viewport) location filter: four comma-separated
            decimal-degree numbers
            `min_longitude,min_latitude,max_longitude,max_latitude`
            (west,south,east,north), e.g. `-77.1,38.9,-77.0,39.0`. A box whose
            `min_longitude` exceeds `max_longitude` crosses the antimeridian: it
            selects the band running east from `min_longitude` over ±180° to
            `max_longitude`, so there is no need to split it client-side.
            Longitude order is therefore significant — transposed corners read
            as a crossing viewport, not as an error. A viewport 360° or wider
            must be sent as the full range `-180,...,180,...`, which the wrapped
            form cannot express.
        - name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            title: Page
            description: >-
              1-indexed page number; increment it to fetch subsequent pages.
              `search_assets` pages by number rather than by cursor. A search
              with a content criterion ranks a fixed top-200 candidate
              population by relevance, so pages beyond that population are
              empty. A structured-filter-only search (album, people, date range
              — no content criterion) returns the full matching set
              newest-first, paginated without that cap.
            default: 1
          description: >-
            1-indexed page number; increment it to fetch subsequent pages.
            `search_assets` pages by number rather than by cursor. A search with
            a content criterion ranks a fixed top-200 candidate population by
            relevance, so pages beyond that population are empty. A
            structured-filter-only search (album, people, date range — no
            content criterion) returns the full matching set newest-first,
            paginated without that cap.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            maximum: 200
            minimum: 1
            title: Limit
            description: Maximum number of results per page (1–200). Defaults to 20.
            default: 20
          description: Maximum number of results per page (1–200). Defaults to 20.
        - name: include
          in: query
          required: false
          schema:
            anyOf:
              - items:
                  type: string
                type: array
              - type: 'null'
            description: >-
              Opt-in expansion fields. Supported values: `metadata`
              (camera/EXIF/GPS and location names), `faces`, `people`, `metrics`
              (ML quality scores), `file_data` (a group token populating the
              nested `file_data` object with the file/provenance scalars
              `device_asset_id`, `device_id`, `file_created_at`,
              `file_modified_at`, `checksum`, `checksum_sha1`,
              `file_size_bytes`), and `variants` (every `asset_urls` rung beyond
              the lean one. Without it `asset_urls` carries only its lean rung —
              `thumbnail` for an image, or `thumbnail_image` for a video — so
              callers that render non-thumbnail variants or download the current
              rendering must pass it). Accepts multiple `include=` query params
              or a single comma-delimited value (e.g. `include=faces,people`).
              Unknown values return 422. When omitted, only the lean core is
              returned (`id`, `mime_type`, `local_datetime`, dimensions,
              `description`, `thumbhash`, `asset_urls`, `kind`,
              `current_version_id`) and each data field above is null/absent
              until you request it.
            title: Include
          description: >-
            Opt-in expansion fields. Supported values: `metadata`
            (camera/EXIF/GPS and location names), `faces`, `people`, `metrics`
            (ML quality scores), `file_data` (a group token populating the
            nested `file_data` object with the file/provenance scalars
            `device_asset_id`, `device_id`, `file_created_at`,
            `file_modified_at`, `checksum`, `checksum_sha1`, `file_size_bytes`),
            and `variants` (every `asset_urls` rung beyond the lean one. Without
            it `asset_urls` carries only its lean rung — `thumbnail` for an
            image, or `thumbnail_image` for a video — so callers that render
            non-thumbnail variants or download the current rendering must pass
            it). Accepts multiple `include=` query params or a single
            comma-delimited value (e.g. `include=faces,people`). Unknown values
            return 422. When omitted, only the lean core is returned (`id`,
            `mime_type`, `local_datetime`, dimensions, `description`,
            `thumbhash`, `asset_urls`, `kind`, `current_version_id`) and each
            data field above is null/absent until you request it.
      responses:
        '200':
          description: Successful Response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchAssetsResponse'
        '401':
          description: Missing, invalid, or expired credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            The credentials are valid but not authorized for this operation —
            for example an API key whose action or library scope excludes it, or
            a credential type this operation does not accept.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: Validation Error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HTTPValidationError'
        '429':
          description: >-
            Rate limit exceeded. Retry after the interval in the `Retry-After`
            header.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
components:
  schemas:
    SearchAssetsResponse:
      properties:
        data:
          items:
            $ref: '#/components/schemas/SearchResultItem'
          type: array
          title: Data
          description: >-
            Text-query matches use the configured reranker over the first 50
            Reciprocal Rank Fusion candidates, with fail-open RRF ordering.
            Image-only matches use RRF across available stages.
            Structured-filter-only searches retain newest-first capture-date
            ordering.
      type: object
      required:
        - data
      title: SearchAssetsResponse
    ErrorResponse:
      title: ErrorResponse
      type: object
      properties:
        detail:
          title: Detail
          type: string
          description: Human-readable explanation of the error.
      required:
        - detail
    HTTPValidationError:
      properties:
        detail:
          items:
            $ref: '#/components/schemas/ValidationError'
          type: array
          title: Detail
      type: object
      title: HTTPValidationError
    SearchResultItem:
      properties:
        asset:
          $ref: '#/components/schemas/AssetResponse'
          description: The matching asset.
        distance:
          anyOf:
            - type: number
            - type: 'null'
          title: Distance
          description: >-
            Best available dense-stage cosine distance (lower is more similar).
            This is attribution only: text results use reranker order when
            reranking succeeds and RRF order on fallback; image-only results use
            RRF. Results are never ordered by this distance. Null for
            sparse-only and structured-filter-only matches.
      type: object
      required:
        - asset
        - distance
      title: SearchResultItem
    ValidationError:
      properties:
        loc:
          items:
            anyOf:
              - type: string
              - type: integer
          type: array
          title: Location
        msg:
          type: string
          title: Message
        type:
          type: string
          title: Error Type
        input:
          title: Input
        ctx:
          type: object
          title: Context
      type: object
      required:
        - loc
        - msg
        - type
      title: ValidationError
    AssetResponse:
      properties:
        id:
          type: string
          title: Id
          description: Unique asset identifier with 'asset_' prefix
        mime_type:
          type: string
          title: Mime Type
          description: >-
            MIME type of the current rendering (e.g., 'image/jpeg',
            'video/mp4').
        original_file_name:
          type: string
          title: Original File Name
          description: Filename the asset was uploaded under.
        local_datetime:
          type: string
          format: date-time
          title: Local Datetime
          description: When the photo/video was taken, in the device's local timezone
        file_data:
          anyOf:
            - $ref: '#/components/schemas/FileDataResponse'
            - type: 'null'
          description: >-
            File/provenance scalars (device IDs, file timestamps, checksums,
            file size) grouped into one nested object. `null` when not requested
            via `include=file_data`; when present, every field is populated
            (`checksum_sha1` may still be `null` for legacy rows).
        created_at:
          type: string
          format: date-time
          title: Created At
          description: When this asset record was created in the database
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: When this asset record was last updated
        metadata:
          anyOf:
            - $ref: '#/components/schemas/MetadataResponse'
            - type: 'null'
          description: >-
            Asset metadata — camera/EXIF fields, GPS, and location names. `null`
            when not requested via `include=metadata`.
        metrics:
          anyOf:
            - additionalProperties:
                anyOf:
                  - type: number
                  - type: 'null'
              type: object
            - type: 'null'
          title: Metrics
          description: >-
            ML-generated quality scores and other metrics. `null` when not
            requested via `include=metrics`.
        asset_urls:
          anyOf:
            - additionalProperties:
                $ref: '#/components/schemas/AssetVariant'
              type: object
            - type: 'null'
          title: Asset Urls
          description: >-
            Named asset variants. Images: 'original', 'thumbnail', 'small',
            'preview', 'fullsize'. Videos: 'original', plus 'thumbnail_image',
            'small_image', 'preview_image', 'fullsize_image' pointing at the
            extracted still. 'original' is served with a Content-Disposition
            attachment header (signed 'dl' filename param) so a top-level
            navigation saves it to disk, while inline subresource loads
            (<video>, fetch) still render it. Variant URLs are stable: a derived
            variant may briefly 404 until its artifact is generated, then serve
            from the same URL.
        description:
          anyOf:
            - type: string
            - type: 'null'
          title: Description
          description: >-
            AI-generated description of the asset's content, quality, and
            composition. null means description generation has not yet run;
            empty string means the model refused to describe the asset. Distinct
            from metadata.description (camera-embedded EXIF metadata).
        thumbhash:
          anyOf:
            - type: string
            - type: 'null'
          title: Thumbhash
          description: >-
            Base64-encoded ThumbHash placeholder (~28 chars). Clients decode
            with the `thumbhash` library (JS / Swift / Kotlin) to render an
            instant blurred preview before the CDN thumbnail arrives. `null`
            while generation is pending.
        faces:
          anyOf:
            - items:
                $ref: '#/components/schemas/FaceResponse'
              type: array
            - type: 'null'
          title: Faces
          description: >-
            All faces detected in this asset. `null` when not requested via
            `include=faces`; `[]` when requested but the asset has no faces.
        people:
          anyOf:
            - items:
                $ref: '#/components/schemas/PersonResponse'
              type: array
            - type: 'null'
          title: People
          description: >-
            All unique people identified in this asset (deduplicated from
            faces). `null` when not requested via `include=people`; `[]` when
            requested but none are identified.
        kind:
          type: string
          title: Kind
          description: >-
            What produced the current rendering: `original` (the upload), `edit`
            (a client-baked edit), or `external:<service>`. The namespace is
            open — derive edited-ness as `kind != "original"`.
        current_version_id:
          type: string
          title: Current Version Id
          description: >-
            ID (`asset_version_` prefix) of the current version, which the
            top-level rendering fields describe. Pass it as the expected current
            version on version writes so a racing write is rejected.
        width:
          type: integer
          title: Width
          description: Width of the current rendering in pixels.
          default: 0
        height:
          type: integer
          title: Height
          description: Height of the current rendering in pixels.
          default: 0
        duration:
          anyOf:
            - type: number
            - type: 'null'
          title: Duration
          description: >-
            Video length in seconds. `null` for images and for videos whose
            duration has not been extracted yet.
        trashed_at:
          anyOf:
            - type: string
              format: date-time
            - type: 'null'
          title: Trashed At
          description: >-
            When this asset was moved to trash (ISO 8601, UTC). `null` for live
            assets. Trashed assets are excluded from default list/search results
            and are purged after the configured retention window.
        stack_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Stack Id
          description: >-
            ID of the stack this asset belongs to (`asset_stack_` prefix), or
            `null` when the asset is not part of a stack. Group assets by this
            value to collapse a stack into a single tile; the stack's own cover
            and member count are not carried on the asset. Distinct from
            `metadata.auto_stack_id`, which is the camera's in-EXIF
            `MakerNotes:AutoStackID` string — this is the server-assigned
            foreign key to the asset's stack.
      type: object
      required:
        - id
        - mime_type
        - original_file_name
        - local_datetime
        - created_at
        - updated_at
        - kind
        - current_version_id
      title: AssetResponse
      description: |-
        Represents a photo or video asset with metadata and access URLs.

        Top-level fields describe the asset's current rendering (the version
        identified by `current_version_id`) unless they explicitly name the
        original upload, like `original_file_name` and the `file_data` group.
    FileDataResponse:
      properties:
        device_asset_id:
          type: string
          title: Device Asset Id
          description: Original asset identifier from the device that uploaded this asset.
        device_id:
          type: string
          title: Device Id
          description: Identifier of the device that uploaded this asset.
        file_created_at:
          type: string
          format: date-time
          title: File Created At
          description: When the file was created on the uploading device.
        file_modified_at:
          type: string
          format: date-time
          title: File Modified At
          description: When the file was last modified on the uploading device.
        checksum:
          type: string
          title: Checksum
          description: >-
            Base64-encoded SHA-256 hash of the asset contents for duplicate
            detection and integrity.
        checksum_sha1:
          anyOf:
            - type: string
            - type: 'null'
          title: Checksum Sha1
          description: >-
            Base64-encoded SHA-1 hash of the asset contents. `null` for older
            assets that have no SHA-1.
        file_size_bytes:
          type: integer
          title: File Size Bytes
          description: >-
            Size of the uploaded file in bytes. Each rendering's own size is on
            its row in the asset's version listing.
      type: object
      required:
        - device_asset_id
        - device_id
        - file_created_at
        - file_modified_at
        - checksum
        - file_size_bytes
      title: FileDataResponse
      description: >-
        File/provenance scalars describing the uploaded *file* (not its
        content).


        Returned only when requested via ``include=file_data``; the whole object
        is

        ``null`` otherwise. When present, every field carries its real value —

        ``checksum_sha1`` is the lone exception (``null`` for legacy rows that
        never

        had a SHA-1). This nested object is the home for the file/provenance
        group.
    MetadataResponse:
      properties:
        asset_id:
          type: string
          title: Asset Id
          description: ID of the asset this metadata belongs to
        created_at:
          type: string
          format: date-time
          title: Created At
          description: When this metadata record was created
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: When this metadata record was last updated
        make:
          anyOf:
            - type: string
            - type: 'null'
          title: Make
          description: Camera manufacturer (e.g., 'Canon', 'Nikon')
        model:
          anyOf:
            - type: string
            - type: 'null'
          title: Model
          description: Camera model (e.g., 'EOS 5D Mark IV')
        orientation:
          anyOf:
            - type: integer
            - type: 'null'
          title: Orientation
          description: >-
            Image orientation value (1-8) indicating rotation/flip: 1=normal,
            2=mirror horizontal, 3=rotate 180°, 4=mirror vertical, 5=mirror
            horizontal+rotate 90° CW, 6=rotate 90° CW, 7=mirror
            horizontal+rotate 90° CCW, 8=rotate 90° CCW
        raw_width:
          anyOf:
            - type: integer
            - type: 'null'
          title: Raw Width
          description: Pre-rotation raw width; null when not available
        raw_height:
          anyOf:
            - type: integer
            - type: 'null'
          title: Raw Height
          description: Pre-rotation raw height; null when not available
        modified_datetime:
          anyOf:
            - type: string
              format: date-time
            - type: 'null'
          title: Modified Datetime
          description: When the file was last modified, with timezone offset if available
        original_datetime:
          anyOf:
            - type: string
              format: date-time
            - type: 'null'
          title: Original Datetime
          description: >-
            When the photo was originally taken, with timezone offset if
            available
        digitized_datetime:
          anyOf:
            - type: string
              format: date-time
            - type: 'null'
          title: Digitized Datetime
          description: When the photo was digitized, with timezone offset if available
        lens_model:
          anyOf:
            - type: string
            - type: 'null'
          title: Lens Model
          description: Lens model used (e.g., 'EF 24-70mm f/2.8L II USM')
        f_number:
          anyOf:
            - type: number
            - type: 'null'
          title: F Number
          description: Aperture f-stop value (e.g., 2.8, 5.6)
        focal_length:
          anyOf:
            - type: number
            - type: 'null'
          title: Focal Length
          description: Focal length in millimeters
        iso:
          anyOf:
            - type: integer
            - type: 'null'
          title: Iso
          description: ISO sensitivity value (e.g., 100, 800, 3200)
        exposure_time:
          anyOf:
            - type: number
            - type: 'null'
          title: Exposure Time
          description: Shutter speed in seconds (e.g., 0.001 for 1/1000s)
        exposure_bias:
          anyOf:
            - type: number
            - type: 'null'
          title: Exposure Bias
          description: Exposure compensation in EV (e.g., -1.0, +0.5)
        latitude:
          anyOf:
            - type: number
            - type: 'null'
          title: Latitude
          description: GPS latitude in decimal degrees
        longitude:
          anyOf:
            - type: number
            - type: 'null'
          title: Longitude
          description: GPS longitude in decimal degrees
        altitude:
          anyOf:
            - type: number
            - type: 'null'
          title: Altitude
          description: GPS altitude in meters
        city:
          anyOf:
            - type: string
            - type: 'null'
          title: City
          description: City name
        state:
          anyOf:
            - type: string
            - type: 'null'
          title: State
          description: State/province name
        country:
          anyOf:
            - type: string
            - type: 'null'
          title: Country
          description: Country name
        country_code:
          anyOf:
            - type: string
            - type: 'null'
          title: Country Code
          description: ISO 3166-1 alpha-2 country code (e.g., 'US', 'JP')
        sublocation:
          anyOf:
            - type: string
            - type: 'null'
          title: Sublocation
          description: Neighborhood or district
        place_name:
          anyOf:
            - type: string
            - type: 'null'
          title: Place Name
          description: Landmark or point-of-interest name
        timezone:
          anyOf:
            - type: string
            - type: 'null'
          title: Timezone
          description: IANA timezone identifier (e.g., 'America/Los_Angeles')
        display_label:
          anyOf:
            - type: string
            - type: 'null'
          title: Display Label
          description: >-
            Human-readable location label. Picks the most specific available
            identifier (place_name > sublocation > city > country) and appends
            broader context (city, then state-or-country). Example: 'Golden Gate
            Bridge, San Francisco, California'. Null when no location fields are
            populated.
        description:
          anyOf:
            - type: string
            - type: 'null'
          title: Description
          description: Image description or caption
        fps:
          anyOf:
            - type: number
            - type: 'null'
          title: Fps
          description: Frame rate for video files
        live_photo_cid:
          anyOf:
            - type: string
            - type: 'null'
          title: Live Photo Cid
          description: Live photo content identifier
        projection_type:
          anyOf:
            - type: string
            - type: 'null'
          title: Projection Type
          description: Projection type (e.g., for 360° photos)
        auto_stack_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Auto Stack Id
          description: Identifier for automatic photo stacking
        rating:
          anyOf:
            - type: integer
            - type: 'null'
          title: Rating
          description: User or camera rating (typically 1-5 stars)
      type: object
      required:
        - asset_id
        - created_at
        - updated_at
      title: MetadataResponse
      description: Metadata for an asset — camera/EXIF fields, GPS, and location names.
    AssetVariant:
      properties:
        url:
          type: string
          title: Url
          description: URL to fetch this image variant
        mimetype:
          type: string
          title: Mimetype
          description: MIME type of the served image
        width:
          anyOf:
            - type: integer
            - type: 'null'
          title: Width
          description: Target width in pixels (null if unknown)
      type: object
      required:
        - url
        - mimetype
      title: AssetVariant
      description: A single image variant with its URL, MIME type, and target width.
    FaceResponse:
      properties:
        id:
          type: string
          title: Id
          description: Unique face identifier with 'face_' prefix
        asset_id:
          type: string
          title: Asset Id
          description: ID of the asset containing this face
        person_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Person Id
          description: ID of the person this face belongs to (if identified)
        bounding_box:
          additionalProperties:
            type: integer
          type: object
          title: Bounding Box
          description: Face location as {x, y, w, h} coordinates in pixels
        confidence:
          anyOf:
            - type: number
            - type: 'null'
          title: Confidence
          description: >-
            Detector confidence on a 0-1 scale; higher is more confident among
            faces detected under the same configuration (values are not
            comparable across detector generations). Null on legacy faces
            without a stored score and on manually added faces.
        source:
          type: string
          enum:
            - automatic
            - manual
          title: Source
          description: >-
            How this face was added: 'automatic' for detector-found faces,
            'manual' for user-drawn face boxes.
        timestamp_ms:
          anyOf:
            - type: integer
            - type: 'null'
          title: Timestamp Ms
          description: For video files, timestamp in milliseconds when face appears
        asset_urls:
          anyOf:
            - additionalProperties:
                $ref: '#/components/schemas/AssetVariant'
              type: object
            - type: 'null'
          title: Asset Urls
          description: 'Asset variants for this face: ''thumbnail'' with face crop'
        created_at:
          type: string
          format: date-time
          title: Created At
          description: When this face was detected and recorded
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: When this face record was last updated
        cluster_assignment:
          anyOf:
            - $ref: '#/components/schemas/ClusterAssignmentResponse'
            - type: 'null'
          description: >-
            Cluster-assignment diagnostics for this face. Populated only when
            `include=cluster_assignment` is requested on the faces endpoint;
            null otherwise. See `ClusterAssignmentResponse` for the shape.
      type: object
      required:
        - id
        - asset_id
        - bounding_box
        - source
        - created_at
        - updated_at
      title: FaceResponse
      description: Represents a detected face in an asset with facial recognition data.
    PersonResponse:
      properties:
        id:
          type: string
          title: Id
          description: Unique person identifier with 'person_' prefix
        name:
          anyOf:
            - type: string
            - type: 'null'
          title: Name
          description: Optional name assigned to this person
        birth_date:
          anyOf:
            - type: string
              format: date
            - type: 'null'
          title: Birth Date
          description: Optional birth date of this person
        is_hidden:
          type: boolean
          title: Is Hidden
          description: Whether this person should be hidden from the UI
        is_favorite:
          type: boolean
          title: Is Favorite
          description: Whether this person is marked as a favorite
        asset_count:
          anyOf:
            - type: integer
            - type: 'null'
          title: Asset Count
          description: >-
            Number of unique photos this person appears in, or null if not
            computed
        thumbnail_face_id:
          anyOf:
            - type: string
            - type: 'null'
          title: Thumbnail Face Id
          description: ID of the face resource used as this person's thumbnail
        asset_urls:
          anyOf:
            - additionalProperties:
                $ref: '#/components/schemas/AssetVariant'
              type: object
            - type: 'null'
          title: Asset Urls
          description: >-
            Asset variants from this person's thumbnail face. May be null when
            embedded in an AssetResponse; use /api/people endpoints for full
            person data.
        cluster_metrics:
          anyOf:
            - $ref: '#/components/schemas/ClusterMetricsResponse'
            - type: 'null'
          description: >-
            Cohesion metrics for this person's face cluster. Populated only when
            `include=cluster_metrics` is requested on the people endpoint, and
            only for persons with a populated centroid (newly-created empty
            Persons will have null). See `ClusterMetricsResponse` for the shape.
        created_at:
          type: string
          format: date-time
          title: Created At
          description: When this person record was created
        updated_at:
          type: string
          format: date-time
          title: Updated At
          description: When this person record was last updated
      type: object
      required:
        - id
        - is_hidden
        - is_favorite
        - created_at
        - updated_at
      title: PersonResponse
      description: Represents a person identified through face clustering and recognition.
    ClusterAssignmentResponse:
      properties:
        distance_to_person:
          anyOf:
            - type: number
            - type: 'null'
          title: Distance To Person
          description: >-
            Cosine distance from the face's embedding to its currently-assigned
            Person's centroid. Lower = better fit. Null when the face is
            unassigned or when the assigned Person has no centroid.
        candidates:
          items:
            $ref: '#/components/schemas/FaceCandidatePersonResponse'
          type: array
          title: Candidates
          description: >-
            Persons in the same library that pass the same gate shape as
            production face assignment, surfaced with deliberately relaxed
            thresholds so the list is a superset of what the automated path
            would admit. Sorted ascending by distance. Excludes the face's
            currently-assigned Person (its distance is in `distance_to_person`).
            Empty when no eligible Persons pass the gate.
      type: object
      title: ClusterAssignmentResponse
      description: |-
        Per-face cluster-assignment diagnostics: how well the face fits its
        currently-assigned Person, and which other Persons are nearby in
        embedding space. Surfaced via ``include=cluster_assignment`` on the
        faces endpoints — used by the operator-facing face cleanup dashboard
        to triage mis-clustered faces.
    ClusterMetricsResponse:
      properties:
        pairwise_p90:
          type: number
          title: Pairwise P90
          description: >-
            90th-percentile pairwise cosine distance between faces in this
            person's cluster. Lower = more cohesive cluster; loose clusters
            (higher pairwise_p90) are gated out of the face-assignment path to
            prevent further drift.
        pairwise_mean:
          type: number
          title: Pairwise Mean
          description: >-
            Mean pairwise cosine distance between faces in this person's
            cluster.
        face_count:
          type: integer
          title: Face Count
          description: >-
            Number of faces that fed into the centroid and pairwise metrics.
            This is the cluster-membership count, **not** the same as
            `asset_count` — `face_count` counts every face row, while
            `asset_count` counts distinct assets (one asset can contribute
            multiple faces of the same person).
      type: object
      required:
        - pairwise_p90
        - pairwise_mean
        - face_count
      title: ClusterMetricsResponse
      description: |-
        Cohesion metrics for a Person's face cluster — surfaced via
        ``include=cluster_metrics`` on the people endpoints. These describe how
        tight the cluster is in embedding space (lower = more cohesive) and
        drive both the production face-assignment cohesion gate and the
        operator-facing face cleanup dashboard.
    FaceCandidatePersonResponse:
      properties:
        person_id:
          type: string
          title: Person Id
          description: Person ID (with 'person_' prefix) of the candidate.
        name:
          anyOf:
            - type: string
            - type: 'null'
          title: Name
          description: >-
            Display name of the candidate Person, or null for unnamed clusters.
            Candidates surface the same Persons production assignment considers,
            which includes unnamed clusters.
        distance:
          type: number
          title: Distance
          description: >-
            Cosine distance from the face's embedding to this Person's centroid
            (lower = closer).
      type: object
      required:
        - person_id
        - distance
      title: FaceCandidatePersonResponse
      description: |-
        A Person whose centroid is close enough to a given face's embedding
        that it would be considered for assignment — surfaced under
        ``ClusterAssignmentResponse.candidates``.
  securitySchemes:
    apiKeyAuth:
      type: http
      scheme: bearer
      description: >-
        Gumnut API key (`apikey_...`) sent as a Bearer token in the
        `Authorization` header. Create and manage keys in the Gumnut app, or
        with the API key endpoints while signed in to it.
    oauthJwtAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        Short-lived JWT obtained from the OAuth token exchange, sent as a Bearer
        token in the `Authorization` header.

````