Repository navigation
Dev Backend API
The geniusai-server exposes a REST API over HTTP (default port 19819). All responses use JSON with the structure { "results": ..., "error": ..., "warning": ... }.
This reference is intended for developers integrating with or extending the backend. Regular users interact through the Lightroom plugin and do not need to call these endpoints directly.
Every request needs the backend's access token as
Authorization: Bearer <token>. Without it, or with another one, the answer is
a 403 with "error_code": "api_token" (the status alone would be ambiguous:
the agent bridge answers 401 for a session that has to register again). The
check sits in front of the whole app (lrg_api::api_auth), outside the router
the MCP tools and the chat call in-process.
-
Where the token comes from. The backend makes it once and keeps it in a
file only the user can read:
~/Library/Application Support/LrGeniusAI/api-tokenon macOS,%USERPROFILE%\AppData\Local\LrGeniusAI\api-tokenon Windows,$XDG_CONFIG_HOME/LrGeniusAI/api-token(or~/.config/…) elsewhere (lrg_common::api_token).LRG_API_TOKENreplaces the file with a fixed token.geniusai-server tokenprints it;geniusai-server token --newmakes a new one, which a running backend uses once it restarts. The Docker image keeps the file in its/datavolume. -
How the plugin gets it. For a backend on this computer it reads the
file (that it can is the proof of being the same user). For one elsewhere
the user pastes it under Plug-in Manager → Backend Server → Access token
(
Secrets.BACKEND_TOKEN); a "local" address without a file, such as a container's published port, falls back to that too. When the backend refuses the token, the plugin reads the file once more and retries once (a token made since), then tells the user what to do. -
Without the token:
GET /ping,GET /versionandPOST /version/check(a plugin must learn what it is talking to, and that it is too old, before it can get in),/mcpand/v1/chat/*(they check their own tokens), and the HTML ofGET /v1/ui/people,GET /v1/ui/searchandGET /v1/ui/chat(a browser cannot add a header when it opens an address; the page holds no data, takes the token from the#fragmentand sends it itself). Only the HTML: what the search page shows — the context the plugin posts, the results, the previews ofGET /v1/photos/thumbnail— needs the token like any other data, which is why the page fetches previews with the header and shows them asblob:URLs instead of pointing<img src>at the route. -
Also refused, token or not: a request from a page on another site (an
Originthat is not this server), and — when the backend listens only on this computer (GENIUSAI_HOSTis a loopback address, the default) — any request whoseHostdoes not name this computer, which is how a DNS-rebinding page arrives.
Minimal liveness check. Returns {"status": "ok"}.
Returns local model load state. Each *_model field is "loaded",
"not_loaded", or "failed", with a matching *_error carrying the message
when it is "failed".
| Field | What it covers |
|---|---|
clip_model / clip_error
|
The SigLIP2 pair, as it has always been. "failed" if either tower failed, "loaded" if either is resident. |
clip_image_model / clip_image_error
|
The SigLIP2 image tower alone — what indexing and the training path embed with. |
clip_text_model / clip_text_error
|
The SigLIP2 text tower alone — what search, keyword lookup and the CLIP-IQA prompt sets embed with. |
aesthetic_model / aesthetic_error
|
AestheticSigLIP, the photo quality scorer. Its own backbone, loaded and idle-unloaded on its own clock. |
face_model / face_error
|
YuNet + the active recognizer (FaceNet or AuraFace). |
face_recognizer |
Which recognizer is active: "facenet" or "auraface" (see GET /v1/faces/recognizer). |
face_state_model / face_state_error
|
MediaPipe Face Mesh V2 + Blendshape V2, which read eye state, smile and gaze for culling. |
subject_model / subject_error
|
RF-DETR-Seg Nano, which finds a photo's subject so culling can measure focus on it. |
species_model / species_error
|
BioCLIP 2. |
The two SigLIP2 towers load and idle-unload independently, so an
embeddings-only indexing run leaves clip_image_model: "loaded" and
clip_text_model: "not_loaded", and a semantic search does the opposite.
clip_model is kept as the pair so that a client reading only that field still
learns that something failed rather than missing a failure on the tower it does
not ask about; the per-tower fields are additive and a server older than them
does not send them.
It does not report cloud/local LLM provider availability —
the backend has no stored API keys or base URLs to probe on a bare GET. The
plugin checks provider availability itself (stored keys, an Other AI server
address, and whether POST /v1/llm/providers/models lists anything for the
built-in engines, Ollama and LM Studio); see SearchIndexAPI.getDetailedHealth() in
APISearchIndex.lua, which backs the Plugin Manager's "System Health" panel
and the Setup Wizard.
Returns the running backend version string.
Checks whether the backend version is compatible with the plugin version passed in the request body.
Called by the Lightroom plugin on first connect. Accepts catalog/configuration parameters and initializes per-catalog state.
Returns the list of available AI models grouped by provider (gemini, chatgpt, anthropic, ollama, lmstudio, openai_compatible, llamacpp, mlx). The two local backends report what is installed on disk, so they are empty when no local model has been downloaded.
Body (all optional; POST so that no key ends up in a URL):
| Field | Description |
|---|---|
openai_apikey, gemini_apikey, anthropic_apikey
|
Cloud keys. Without one, that provider's list is empty. |
ollama_base_url, lmstudio_base_url
|
Where to probe Ollama and LM Studio. Absent means their default address on this computer — which is all the current plugin sends. |
server_url, server_apikey
|
The user's own OpenAI-compatible server ("Other AI server" in the plugin): OpenRouter, llama.cpp llama-server, LiteLLM, vLLM, or LM Studio/Ollama on another machine. The address is normalised — no scheme means http:// for local-network hosts and https:// otherwise, a pasted …/chat/completions is cut back, and no path means /v1. The key is optional. |
{
"models": { "openai_compatible": ["google/gemini-2.5-flash", "…"], "lmstudio": [], "…": [] },
"servers": { "openai_compatible": { "label": "OpenRouter" } },
"aliases": { "mlx": { "<old name>": "<name offered now>" } },
"warnings": ["Other AI server: OpenRouter rejected the API key. …"]
}aliases maps names a model used to be offered under, and that the backend still accepts as model, onto its current name: MLX models from the Hugging Face cache were listed under their snapshot hash before they were listed under their repo name. The plugin uses it to keep a saved choice selected.
A provider that is only probed and not running (Ollama, LM Studio) is simply empty. The user's own server is different: they entered it, so any failure to list it — unreachable, rejected key, malformed address — is reported in warnings in words the user can act on. So is an Anthropic key that is set but does not work (rejected, no credit, unreachable API): its listing failure comes back in warnings too. OpenAI, Gemini and Anthropic listings time out after 10 s so that a hanging network cannot hold up the whole list. The anthropic list is Anthropic's own /v1/models, filtered on each model's published capabilities: a model that cannot take images or structured outputs is left out. servers.openai_compatible.label (OpenRouter, or host and port) is what the plugin shows in front of that server's models. Only models that can take a photo are listed: entries a server marks as text-only (OpenRouter's architecture.input_modalities) and embedding models are dropped. An LM Studio entered as the user's server is not also listed under lmstudio.
Returns recent log lines from the server log.
Streams raw log file content. log_type can be server or plugin.
Gracefully shuts down the backend process.
Restarts the backend process. Note: this endpoint is known to be unreliable on Windows (see Troubleshooting).
Unloads heavy ML models from memory without shutting the server down (useful to reclaim VRAM/RAM between sessions).
Triggers an in-place backend self-update from the latest GitHub release.
Loopback-only. The caller supplies the manifest, so its checksums say nothing
about where the files come from: a binary URL other than
https://github.com/LrGenius/LrGeniusAI/releases/download/… is a 403, a
plugin file from anywhere but https://raw.githubusercontent.com/LrGenius/LrGeniusAI/…
or with a path that leaves the plugin folder is skipped with a warning.
Indexes a batch of photos sent as multipart file uploads. Generates embeddings and/or AI metadata depending on options.
Key request fields:
| Field | Type | Description |
|---|---|---|
photo_id |
string | Stable file-based photo identifier |
catalog_id |
string | Lightroom catalog identifier |
provider |
string | LLM provider (gemini, chatgpt, anthropic, ollama, lmstudio, openai_compatible, llamacpp, mlx) |
model |
string | Model name within the provider (for llamacpp the GGUF file name, for mlx the model directory name) |
api_key |
string | Key for chatgpt/gemini/anthropic (required) or openai_compatible (optional) |
server_url |
string |
openai_compatible only: the server address, normalised as for /v1/llm/providers/models
|
llm_n_ctx |
int |
llamacpp only: context window override (0/absent = default) |
llm_n_parallel |
int |
llamacpp only: photos decoded concurrently |
llm_gpu_layers |
int |
llamacpp only: layers offloaded to the GPU (0 = CPU only) |
temperature |
float | Sampling temperature, default 0.1. Only the local providers (llamacpp, ollama, lmstudio, openai_compatible) use it: OpenAI reasoning models get a fixed 1.0, Anthropic and Gemini 3 are sent none, and MLX's guided generation samples on its own settings |
reasoning_effort |
string | The plug-in's Analysis depth: low (default), medium or high. Sent as OpenAI reasoning_effort, Gemini thinkingLevel (3.x) or thinkingBudget (2.5), and Anthropic output_config.effort where the model lists that level; room for thinking (4096 / 8192 / 16384 tokens) is added to max_tokens for a model that thinks. Local providers ignore it. Any other value is a 400 naming it, not a silent fallback |
generate_metadata |
bool | Generate keywords/title/caption/alt_text |
create_embeddings |
bool | Create SigLIP2 semantic embeddings. Also scores the photo's aesthetic and names its genre (see the aesthetic task below) |
detect_faces |
bool | Run face detection on the photo |
regenerate |
bool | Re-process even if data already exists |
replace_ss |
bool | Rewrite ß as ss in the generated title, caption, alt text and keywords. Applied after the model answers, and only to those fields — never to the filename |
exposure_bias |
float | Exposure compensation in EV. Stored, and the decisive signal for culling's bracket detection. Omit when the camera did not record it; never default it to 0 |
is_raw |
bool | Whether the original is raw. Stored on the photo so later work (style training above all) can keep raw and rendered originals apart. Omit when unknown |
original_filename |
string | Single-photo upload only. The original file's name in the catalog (018A0344.CR3), for when the uploaded part is a rendition (018A0344.jpg). Stored and shown as the photo's filename; the part's own name still decides how the bytes are decoded. Ignored for a batch |
extra_context |
object | Optional context hints (folder, date, GPS, keywords) |
prompt |
string | The system prompt (the plug-in's "Instructions / Prompt" template). It replaces the built-in persona rather than extending it. Trimmed, and dropped when blank: a template the user emptied means "no persona of my own", so the built-in default applies instead of an empty system prompt. The edit routes read the same field the same way |
existing_keywords |
string[] | The photo's keywords, without face tags. Only reaches the prompt when submit_keywords is set |
existing_face_tags |
string[] | Names Lightroom's face recognition put on the photo. Sent apart from existing_keywords and labelled as people in the prompt, so a person's name is not read as a place (issue #315), and asked for by name in the title and caption rather than "a man and a woman" (issue #321) |
submit_face_tags |
bool | Whether existing_face_tags may reach the prompt. Defaults to true, unlike submit_keywords: a client old enough not to send this field only puts the names on the wire when its own keyword switch is on, so trusting what arrived leaves that client's behaviour unchanged. Governs the edit routes too |
location_sublocation, location_city, location_state, location_country, location_country_code
|
string | Where the catalog says the photo was taken. Omit a field the catalog has no value for — an empty string counts as "a place is known" and suppresses the GPS lookup below |
gps_latitude, gps_longitude
|
float | The photo's coordinates. Both or neither; a lone latitude is discarded |
All seven location fields are governed by submit_gps (default on) and, like the
keyword fields, are only read when metadata is generated. submit_keywords and
submit_face_tags are separate switches on purpose: sending "beach, sunset" and
naming the people in a private photo to a cloud provider are different
decisions, and each is expressible without the other.
/v1/index/photos/by-path carries exposure_bias, is_raw, existing_keywords,
existing_face_tags and the seven location fields per image inside the images
array instead, since they are properties of the individual photo.
Where the location comes from, in order. The catalog fields above win,
because they are the most current — a city corrected in Lightroom is never
written back into a raw original. What they leave open is filled from the file
itself: its EXIF/IPTC, read before normalisation, plus any XMP sidecar beside
it. Failing a place name entirely, the coordinates are reverse-geocoded offline
against GeoNames' cities1000 and the prompt says the name was looked up and
how far away it is.
Reading the file's metadata before normalisation is the fix for issue #321: normalising re-encodes every raw original and every JPEG over 2048 px, and the JPEG that comes out has neither EXIF nor IPTC — so location context reached the model only for small exported JPEGs, and silently vanished for anyone using "submit originals" or an export size above 2048 px.
/v1/edit/recipe and /v1/edit/recipe/base64 accept the same
existing_keywords/existing_face_tags pair, under the same switch.
Organism gate. species_prefilter (default true) runs BioCLIP only on photos whose SigLIP2
embedding scores at least species_prefilter_threshold on a fixed set of "is there an animal or
plant" prompts; the rest are checked and stored as rank = none. The default threshold
(DEFAULT_SPECIES_PREFILTER_THRESHOLD, 0.10) belongs to the current SigLIP2 model, because the score
depends on how far apart that model puts the prompt pairs. It was 0.35 for the 384 center-crop model,
where the same value would skip almost half of the identifiable photos on NaFlex. The NaFlex scores
separate organisms from other photos less cleanly, so the gate is generous and a few non-organisms
reach BioCLIP, which answers them at a coarse rank. Re-measure it with examples/siglip_thresholds.rs
whenever lrg_ml::siglip::MODEL_ID changes.
Species reaches the prompt, not the request. When tasks includes
species, BioCLIP 2 runs before the LLM call rather than after it, and the
identification is written into that photo's prompt as the answer to use (issue
#326 — a general vision model captioned two rabbits as sheep while the species
field on the same photo read "European Rabbit"). A coarse rank is passed on as
a ceiling instead of a name. There is no request field for this: a photo
identified by an earlier run contributes its stored identification, so a
metadata-only re-run over already-identified photos benefits too. The line sits
in the per-photo half of the split prompt, so the run-constant KV-cache prefix
is unaffected.
Response: {status, success_count, failure_count, error_messages, warnings, results}.
warnings is where a degraded success is reported: the photo was indexed, but
an optional signal was lost — most often "<file> faces: face model is not downloaded yet …" or the same for species. Metadata generation reports here
too: when the LLM returns success but omits a field that was asked for, the
provider says so in its own warning and it arrives as
"<file>: the model returned no caption for this photo — the rest was kept …". These never fail the photo
(success_count still counts it), so a caller that ignores warnings shows the
user a clean run that silently produced worse data. Each entry also rides on its
own photo's results element as warnings, so a grouped caller can attribute it.
Search-page preview. Every pass over a photo — whatever its tasks, and
also when the work itself is skipped because it is already done — stores a small
JPEG of it for the browser search page (crate::thumbnails, served by
GET /v1/photos/thumbnail): 256 px on the long edge, quality 75, made from the
decode the culling signals already pay for. It is written after the photo's row,
so a photo that failed to store leaves none behind, and replaced on every pass,
so a re-index refreshes an edited photo. A preview that could not be made or
saved costs the photo nothing else, and says so in warnings ("<file>: no preview for the browser search page — …").
Eye and face focus. Faces are detected on the working copy (≤2048 px,
GENIUSAI_CONVERT_MAX_EDGE), where an eye in a half-length portrait is a dozen
pixels and says nothing about where the focus landed. So for every photo with
faces, the face step measures focus again on the full-size original — read again
from disk on /v1/index/photos/by-path (a raw through its embedded preview,
lrg_imaging::convert::decode_oriented), or the upload itself, kept until then,
on this route. Never for a photo without faces, and at most two such decodes at
once across all requests. It stores per face face_eye_focus, face_focus,
face_eye_px and face_focus_scale (null when the face was too small) and per
photo cull_eye_focus and cull_face_focus (absent when no prominent face could
be measured) plus cull_focus_version, which tells "too small to measure" from
"indexed before focus was measured". When the original cannot be decoded again,
the readings from the working copy stay and warnings says so ("<file>: eye focus was measured on the reduced working copy …").
Eye state, smile and gaze. In the same step, when the face_state model is
on disk, MediaPipe's face mesh and blendshapes run on the prominent faces (at
most 12, largest first) of the same full-size image, or of the working copy
when there is no larger one (lrg_ml::face_state). Per face the row gets
face_eye_state (open/half/closed, down for lids down on a lowered head,
unknown when the face is turned too far, barely a face or too small), face_eye_closed (the more closed eye),
face_smile, face_eye_squint, face_mouth_open, face_gaze_off_lens (degrees from
the lens, head turn included), face_yaw, face_pitch, face_roll (degrees), the light
on the skin as face_luminance, face_against_surround_stops, face_across_stops and
face_blown, and face_blendshapes (all 52, in the order of
lrg_ml::face_state::BLENDSHAPE_NAMES); all null when the model did not run for it. A
pass that only prepares culling writes no rows of its own, but brings these readings
to the rows an earlier full pass wrote, matched by box, and touches nothing else on them. Per
photo: cull_eye_closed (the most closed prominent eyes), cull_eye_state,
cull_smile (the weakest smile among the prominent faces), cull_eye_squint (the
strongest squint among the prominent faces not smiling), cull_gaze_off_lens (the
prominent face with open eyes looking furthest from the lens), cull_mouth_open (the
most open mouth among those not smiling), cull_face_luminance (the darkest),
cull_face_across_stops (the hardest light) and cull_face_blown (the most blown skin) —
absent when no prominent face could be judged — plus cull_face_state_version and cull_face_state_model when
the model ran on this pass. Without the model
the response says once that a rough eye estimate was used, with the fix
("Photos were analysed without eye state, smile and gaze: …").
Small faces. Faces are detected twice on the working copy: once on a 640 px view of the whole
frame, and again on four tiles of 60 % of its width and height, one per corner
(lrg_ml::yunet::tiles; only when the frame is more than 800 px on its long edge). A tile face is
added after the first pass's faces, which therefore come out exactly as before, when it is narrower
than the first pass is counted on to find (20 px in its view, about 64 px on a 2048 px copy: wider
tile faces were as often texture as faces on the public benchmark), lies whole in its tile, and
overlaps no face already found. It counts for every culling aggregate, and cull_faces_detected
counts it, but it gets no identity: no embedding, no thumbnail and no FACE_TABLE row, so it never
reaches People or key people.
The subject, and focus on it. The cull task, or subject on its own, finds the
photo's subject when the subject model is on disk (lrg_ml::subject, RF-DETR-Seg Nano: COCO
objects with masks, on a 312 px square of the working copy) and measures focus on it and on
the background (lrg_imaging::subject_focus). The subject is the object with the most weight
— the model's score, times the square root of its area, times how central it is — joined by
others of its kind at least a quarter its size (the second person of a couple), and by a person,
animal or vehicle of that size touching it (the rider of a horse, the cyclist on a bicycle);
furniture and street fixtures are never the subject, and nothing under 0.4 % of the frame is. Focus is the
blur effect over tiles of the working copy (a sixth of the subject's size, 32–96 px), the 80th
percentile of the tiles inside the subject and of those clear of it. Per photo:
cull_subject_kind (person/animal/vehicle/object), cull_subject_score,
cull_subject_area, cull_subject_x/_y, cull_subject_focus and cull_background_focus
(absent when too little could be measured), cull_background_streak (how much sharper the
background reads along one axis than across it, high for a pan), cull_subject_mask (the
subject's mask on a 26×26 grid, hex, eight cells to a byte), and always cull_subject_version
and cull_subject_model, also when no subject was found. Without the model the response says
once that sharpness was judged on the whole frame, with the fix.
The face pass then counts only the faces that belong to the subject — a face whose centre lies on
that mask — so a bystander soft behind a shallow depth of field does not reject the keeper, and a
spectator's face that the soft background hides does not make the frame "faceless" beside its
twin. For a person that holds even when no face is on them (an athlete seen from behind): the
frame then has no face that matters. For an animal or a vehicle the faces on it count (the
rider), and when none is, every face (the owner beside the dog). Beside a thing (a cake, a ball)
and without a subject, every face counts. cull_face_count and the cull_face_* and
cull_eye_* aggregates are over the faces that count; cull_faces_detected is every face found,
which is what decides whether the face pass has readings to bring up to date, for People too.
cull_face_subject_version records the subject version the face aggregates were taken against;
a cull pass over a photo whose subject reading is newer runs the face step again.
/v1/index/unprocessed does not count a missing preview as outstanding:
the cull pre-flight and the agent's indexing command ask it too, and would offer
to prepare photos that lack nothing they need. Photos indexed before previews
were stored get theirs from Add Missing Search Previews
(POST /v1/photos/thumbnails/missing, then POST /v1/photos/thumbnail per
photo), which renders only the preview and touches nothing else; the next run
that sends a photo makes one too.
The computer stays awake. Both index routes run through process_batch,
which holds AppState::keep_awake for the batch (crate::keep_awake). On macOS
that is a PreventUserIdleSystemSleep power assertion named "LrGeniusAI is
indexing photos", kept for two minutes after the last batch so the tens of
milliseconds between one photo's request and the next do not let a computer
whose idle timer ran out long ago fall asleep; run_maintenance releases it.
Without it, an unattended run stopped for ~17 minutes at every idle sleep and
crept on only during dark wakes. Elsewhere it does nothing. A failure to take
the assertion is logged once and does not touch the response: nobody asked for
it, and the photos index the same.
Indexes photos using server-side file paths instead of uploading image data (for local-backend setups where the server has filesystem access).
A bounded number of files is read and normalised at a time, so a group of raw originals is
never all resident at once — the normalised JPEG is a few hundred KB and the raw bytes are
released the moment it exists. Decode is pure CPU and parallel across the group, which for a
run without metadata is the largest cost in the request; GENIUSAI_INDEX_DECODE_CONCURRENCY
sets how many run at once (default min(3, cores), 1 restores the strictly sequential
behaviour). Results come back in submission order regardless, so a file that cannot be read is
reported as a failure against its own photo_id in results, not as an anonymous error for
the batch.
llm_batch_size is only meaningful when the run generates metadata. The plugin sends it as
the group size for a llamacpp run, and omits it otherwise — a group formed for decode
throughput must not override a provider's own preferred batch size.
Aesthetic score and genre. The embeddings and cull tasks both imply the aesthetic task,
which can also be asked for alone. It scores the photo with AestheticSigLIP (1 to 9, higher is
better; its own So400m forward pass at 256 patches) and stores aesthetic_score with
aesthetic_model, and names the photo's genre from its SigLIP2 embedding (zero-shot, thirteen
genres or other) as genre, genre_confidence and genre_version. A photo that already has a
score from the current export is skipped unless regenerate_metadata is set. When the scorer is not
downloaded the run still indexes the photos and says so once in the response's warnings (not
once per photo); a genre that could not be made says why the same way.
Returns stored metadata and embedding status for one or more photo_id values.
The response carries an aesthetic block next to species: score, model, outdated (the score
is from another export than this build's, so not comparable with current ones), genre (the stable
id), genre_label and genre_confidence. It is null for a photo with neither. It is a block of
its own, not part of metadata, which is text the plug-in lets the user edit.
The photo's stored search-page preview (see Search-page preview under
POST /v1/index/photos), ?photo_id=<id>, as image/jpeg with
Cache-Control: private, max-age=300. Needs the access token like every data
route; the search page fetches it with the header and shows the bytes as a
blob: URL.
| Status | Body | When |
|---|---|---|
| 200 | the JPEG | a preview is stored |
| 400 | {"error"} |
no photo_id
|
| 404 | {"error", "error_code": "no_thumbnail"} |
none is stored: the photo was indexed before previews were, or not at all. An answer, not a failure — the page draws a placeholder and counts these, and names Add Missing Search Previews as the fix |
Disk cost and lifetime. One file per photo,
<lance root>/thumbnails/<aa>/<sha256 of the photo_id>.jpg (the lance root is
the lrgenius-lance folder beside the bound db_path; <aa> is the hash's
first two hex digits, so no folder holds more than a few hundred files). About
7–20 KB a photo depending on its detail (12.6 KB on average over thirteen sample
photos), roughly 130 MB per 10,000 photos. Files rather than a LanceDB
column, because every metadata scan (search, stats, cleanup) would otherwise
decode them. Deleted with the photo by POST /v1/photos/remove and by the index
cleanup's purge (POST /v1/photos/catalogs/cleanup), only for rows that are
really gone; left out of POST /v1/db/backups, since any indexing run makes
them again.
Which indexed photos have no search-page preview — what the plug-in's Help ▸ Add Missing Search Previews asks before it renders anything. Body (JSON, every field optional):
| Field | Meaning |
|---|---|
photo_ids |
Only these photos (the selection). Left out: every indexed photo. Not a list → 400 |
catalog_id |
Only photos this catalog has (catalog_ids) |
Returns {"photo_ids": [...], "indexed": <n>, "missing": <n>} — indexed
counts the photos the question covered that have an index row, photo_ids
those of them without a preview. A photo that is not indexed is never listed:
a preview belongs to an index row, and the next indexing run makes one anyway.
No database bound → the empty answer. Costs a metadata scan (like
GET /v1/photos/ids) plus a file stat per photo.
Stores the search-page preview of an already indexed photo, without indexing it
again. Multipart: photo_id and one image part — any JPEG/PNG; the plug-in
sends a small export (512 px). The backend scales it down
to the size indexing stores (256 px long edge, quality 75) and writes it where
GET /v1/photos/thumbnail reads it, replacing any earlier one. Nothing else
about the photo changes.
| Status | Body | When |
|---|---|---|
| 200 | {"status": "success", "photo_id"} |
stored |
| 400 | {"error"} |
no photo_id, no image, or an image that cannot be decoded |
| 404 | {"error", "error_code": "not_indexed"} |
the photo has no index row — refused, since nothing would ever delete a preview of a photo the index does not know |
| 503 | {"error"} |
no database is bound |
Returns a list of all indexed photo_id values, optionally filtered by catalog.
Given a list of photo_id values, returns which ones are not yet indexed or are missing specific data.
"Missing" is per task, and where the producer of a field can change it means produced by the producer this build uses, not merely present:
| task | outstanding when |
|---|---|
embeddings |
no embedding, or a stored embedding_model that is not this build's |
faces |
no face rows and no faces_checked, a face_model that is not this build's, or — once its model is on disk — a photo with faces (cull_faces_detected > 0, or cull_face_count on a photo prepared before that existed) whose cull_face_state_version is missing or older, or that a culling pass left with face rows it could not match (face_rows_behind) |
aesthetic (also implied by embeddings and cull) |
no aesthetic_score, or an aesthetic_model that is not this build's — and only when the scorer is on disk, since a missing model cannot be fixed by running again |
genre (with embeddings, cull and aesthetic) |
an embedding but no genre, or a genre_version that is not this build's (the genre list or its prompts changed), and only when the search model is on disk. Made again from the stored embedding, so the photo is not re-embedded |
species |
no species_checked, or a species_model that is not the head on disk |
cull |
faces (cull_face_count > 0) and no cull_face_state_version while the face state model is on disk; no cull_phash, no cull_faces_present key, no capture_time without a capture_time_unknown stamp (grouping needs the time; a photo prepared before the fast path sent it is prepared again once, and one with no time in the catalog or the file's EXIF is stamped so it is not asked for again), or faces (cull_face_count > 0) but no cull_focus_version — prepared before eye and face focus were measured. The cull pass then runs the face step again even on a photo whose faces are already checked; it writes no identities, only the culling fields. Also, while the subject model is on disk, no current cull_subject_version or another cull_subject_model — prepared before the subject was found — or faces (cull_faces_detected > 0) whose cull_face_subject_version is not the photo's cull_subject_version, counted before the subject told them from bystanders |
metadata |
no title, caption, alt_text or keywords |
regenerate_metadata overrides all of them.
It redoes only the tasks of that request. Fields written by a task that is not in the request — the
stored embedding, the species, the AI title, caption and keywords, the catalog membership — are kept: redoing
cull alone does not remove a photo from search.
The two identity fields differ in one respect, deliberately. For faces, a missing face_model
re-queues the photo. For embeddings, a missing embedding_model does not: has_embedding has
existed since before this field, so an absent identity is the normal state of every existing index,
and re-queueing on absence would re-embed a whole catalog to settle a maybe. A present identity that
differs always re-queues.
Absence is rare in practice now: binding a database runs a one-time store upgrade
(lrg_store::upgrade, step 0001-stamp-embedding-model, recorded in upgrades.json in the lance
root) that stamps this build's embedding_model on every stored vector that has none. That is safe
because the field was added while one SigLIP2 export was current: the step stamps
lrg_ml::siglip::LEGACY_MODEL_ID (the 384 center-crop model), not this build's NaFlex model, so
those vectors are re-queued by /v1/index/unprocessed like any other stale one. A row naming another
model keeps its name, and the absent-is-current rule stays for any row an older build writes after
the upgrade has run.
Removes all data (embeddings, metadata, face data, the search-page preview) for a given photo_id.
Removes only the AI-generated metadata fields for a given photo_id, leaving embeddings intact.
The index cleanup: takes photos deleted from a catalog out of its index. The
plugin runs it on its own (a couple of minutes after Lightroom starts, then once
a day, and when a search turns up photos the catalog no longer has) and from
Plug-in Manager → Backend Server → Clean up index now (IndexCleanup.lua).
The logic is lrg_store::cleanup.
Request: catalog_id (required), photo_ids — every photo ID in the
catalog — and confirm_large_removal (bool, default false).
The inventory is sent in one request (including for an empty catalog), since separate batches would each release the photos outside that batch. The plugin takes cached identities as they are (no file access) and resolves only photos that have none yet; it aborts rather than send an incomplete inventory.
Against that inventory, every image row is one of:
| Row | Effect |
|---|---|
| claimed by this catalog, not in the inventory | the catalog lets go (catalog_id removed). Other catalogs keep theirs. A row left in no catalog is stamped orphaned_at (Unix seconds) |
| in the inventory, in no catalog | re-adopted: the catalog is added back and the stamp cleared — a photo removed by mistake and imported again gets its AI data back |
| in no catalog, stamped more than 30 days ago | purged for good: image row, its faces ({photo_id}_n), its species embedding and its search-page preview. Training examples stay |
| in no catalog, no stamp | left alone — rows from before catalog_ids existed look like this |
Every row is read again before it is written, and a purge candidate some catalog claimed since the scan is skipped.
Large removals: when the catalog would let go of more than half of what it
has indexed (and at least 50 photos), nothing changes and the answer is a
409: {"error", "needs_confirmation": true, "would_disassociate": n, "checked": indexed}. That is what an inventory resolved another way than at
indexing time looks like, and the grace period does not cover it — those IDs
never come back. The plugin asks the user and repeats the request with
confirm_large_removal: true.
Response:
| Field | Meaning |
|---|---|
checked |
photos this catalog had indexed |
disassociated |
photos the catalog let go of |
orphaned |
of those, how many are now in no catalog |
readopted |
photos that came back |
purged |
photos deleted for good in this run |
waiting |
photos in no catalog, still within the grace period |
next_purge_at |
Unix seconds when the oldest of those falls due, or null
|
grace_days |
the grace period (30) |
warnings |
purges that failed; the next run retries them |
Associates an existing backend record with a (potentially new) catalog ID.
The plugin may scan the whole catalog for automatic or manual claiming, but
identity caching uses catalog plugin properties and never writes photo metadata
or Lightroom’s lastEditTime. The existing photo_id formats are unchanged.
Runs a semantic search query against indexed photos.
Key request fields:
| Field | Type | Description |
|---|---|---|
term |
string | Natural language search query. Also accepted as a query-string parameter. Optional: without one the search is "the best N" of the scope, ordered by stored aesthetic score, from metadata alone (no text tower, no vectors) |
sort |
string |
relevance, aesthetic or combined. Default relevance with a term, aesthetic without. relevance and combined without a term are a 400 ("need a search term"), as is an unknown value |
min_aesthetic |
float | Only photos whose current aesthetic score is at least this (1–9). A photo with no score from the current export cannot be judged and is left out |
genres |
array | Only photos whose genre is one of these ids (see GET /v1/search/genres). A photo with no genre is left out |
eyes_open, smiling, looking_at_camera
|
bool | Only photos where this holds for everyone who matters in the frame, read off cull_eye_state, cull_smile (0.5 or more) and cull_gaze_off_lens (20° or less). A photo measured to have no faces is simply no match; one with no current reading cannot be judged and is left out and counted |
aesthetic_weight |
float |
combined only, 0–1, default 0.5: the share of the ordering that is the aesthetic score rather than relevance |
catalog_id |
string | Restrict to a specific catalog |
photo_ids |
array | Restrict search to specific photo_id values (uuids is accepted as an alias) |
max_results |
int | Upper bound on results, across both halves of the search |
relevance_strictness |
int | 0–100. 0 disables the knee filter |
search_sources.semantic_siglip |
bool | Include SigLIP embedding similarity |
search_sources.metadata |
bool | Include substring search over the AI metadata |
search_sources.metadata_fields |
array | Which fields to match; defaults to flattened_keywords, alt_text, caption, title
|
catalog_id and photo_ids are independent filters: passing both narrows to
their intersection. (Until August 2026 the metadata half skipped the catalog
check whenever photo_ids was present, leaking results from other catalogs.)
Response: results array of {photo_id, uuid, distance, aesthetic_score, genre, genre_label, has_thumbnail}
(aesthetic_score is null for a photo with no score from the current export;
has_thumbnail says whether GET /v1/photos/thumbnail has a preview for it — a
file check per result, at most max_results of them — so the browser search
page can draw a placeholder without asking). By
default semantic matches come first, ordered by ascending distance; metadata matches follow with
distance: null and fill whatever is left of max_results.
sort=aesthetic orders every match by score, best first. sort=combined orders by
(1 - w) * relevance + w * (score - 1) / 8, where relevance is a semantic hit's distance scaled
between the closest and the farthest of the result set (1 best) and a metadata hit — a literal
match in the title, caption or keywords, which has no distance — counts as fully relevant. In both,
matches without a current score go last, in their earlier order: they are not on the scale, and
a warning says how many.
min_aesthetic, genres and the face questions are applied after the relevance cut, so they never move the cut
(it is computed over the whole candidate list) and can leave fewer than max_results. What they
drop for want of data, rather than for its value, is counted in warnings, since that is a problem
the user can fix by indexing; a photo that was judged and found short of the minimum is simply not a
match. Without a term, photos in scope with no score are counted the same way.
warnings (a list) and warning (the same joined, for clients that predate it) appear whenever
there is something to say; before this the route only ever returned warning for a missing model.
The genres a search can be restricted to, {"genres": [{"id": "macro", "label": "Macro"}, ...]},
other last. From lrg_ml::genre::GENRES, the list the classifier itself uses, so a UI never
offers one the backend cannot name.
Finds photos similar to a reference photo using perceptual hash (phash) or CLIP embeddings.
Key request fields:
| Field | Type | Description |
|---|---|---|
photo_id |
string | Reference photo |
mode |
string |
phash or clip
|
scope_ids |
array | Optional photo_id scope |
max_results |
int | Maximum results |
strictness |
string |
strict, normal, or loose (phash mode) |
Groups a set of photos into similarity clusters (used internally by the culling workflow).
Takes the same request fields as /v1/cull/grade and returns its groups,
warnings and warning (see below); no summary.
Runs the full culling pipeline on a set of photos: grouping, scoring, and classification into picks/alternates/rejects.
Key request fields:
| Field | Type | Description |
|---|---|---|
photo_ids |
array | Photos to cull. Ids with no stored record are skipped and counted in summary.unindexed_count
|
culling_preset |
string |
auto, default, portrait, street, event, sports or wildlife. Absent means default. 400 with available_presets for anything else |
time_delta_seconds |
int | Burst window. Absent means the preset's own |
phash_threshold, clip_threshold
|
float or "auto"
|
Grouping thresholds |
use_iqa |
bool | Score the CLIP-IQA signals (the moment and the kiss gate) over the stored SigLIP2 embeddings. Default true. The aesthetic term is not one of them: it is the AestheticSigLIP score stored with each photo, used whatever this says |
semantic_weight |
float | Overrides the preset's moment weight, for the ranking as well as for deciding whether to score it. Clamped to 0..1 |
key_people |
bool | Judge by the selection's key people (lrg_api::key_people): the persons named in People who are in the selection, and those in at least a fifth of its photos with faces and at least three (lrg_analysis::key_people::KeyPeopleRule). Faces without a person in People are clustered among the selection's for this. In a group where any frame shows one, every frame's face aggregates are summed again from its key faces alone, every one counting however small (no prominence gate), and the reasons about eyes, focus and gaze name the key person (cull_who_*, rank time only, never stored and left out of stored_metadata). Reads FACE_TABLE, so it needs the full face pass. Default false |
target_count, target_fraction
|
int, float 0..1 | Choose this many photos across the whole selection, or this share of them (one or the other; both is a 400), instead of a pick per group (lrg_analysis::selection). See Target below |
highlights_count |
int | With a target, this many highlights among the chosen photos |
use_taste |
bool | Reorder each group by the photographer's learned culling style; see POST /v1/cull/taste
|
reject_margin_scale |
float | Scales the preset's reject_score_delta, the score margin below the pick at which a frame becomes a reject candidate: above 1 fewer rejects, below 1 more. The defect rules (blur, blink, occlusion, exposure) are untouched. Clamped to 0.25..4. The plugin sends 1.5 / 1.0 / 0.6 for its gentle / balanced / strict cull strength |
include_stored_metadata |
bool | Echo each photo's stored cull_* inputs as stored_metadata (for fixtures) |
include_embeddings |
bool | Echo each photo's SigLIP2 embedding as embedding_q8: base64 of a little-endian f32 scale and one signed byte per dimension (lrg_analysis::eval::quantize_embedding, about 1.5 KB). For fixtures, whose replay groups the way the backend did only with it. Absent for a photo without an embedding |
include_debug |
bool | Per-group thresholds and pairwise distances |
Target. With target_count or target_fraction, the ranked groups are joined into
scenes — consecutive groups at most 90 s apart whose picks are at most 0.35 apart in cosine
distance where both have an embedding — and photos are chosen across them by a keep value
comparable between groups: the aesthetic score, blended 0.7/0.3 with the moment where judged,
halved by a clear defect (cull_score is measured against each group and is not comparable).
First the best pick of every scene (the best scenes', when the target is smaller than their
number); then, until the target, the photo with the highest keep value less 0.5 × its cosine
similarity to the closest chosen photo (frames of one group count at least 0.9 alike, embedding
or not) and less 0.1 per place it ranks below its group's pick, plus 0.1 for a group not yet chosen
from and 0.1 per key person (with key_people) not yet in the selection. A reject candidate is chosen only as the only frame of its scene, and is
listed in selection.selected_with_defect. Highlights are the best chosen photos without a
defect, at most each group's best-ranked chosen frame, kept apart the same way. Without a target nothing changes. The weights are a first
version; see Dev-Image-Culling-Benchmark.md for what is measured.
auto chooses the preset per group from the photos' stored genre
(lrg_ml::genre): the preset most of the group's genres call for
(lrg_analysis::culling_config::preset_for_genres), a tie going to the one that asks more
(event, portrait, sports, wildlife, default). portrait → portrait; event, concert and
street → event (a guessed "street" does not switch focus off the way the street preset
does on purpose); sport → sports; wildlife → wildlife; every other genre → default. The
groups themselves are made with the preset most of the whole selection calls for (its burst
window and distances), and each group's prompts are scored with its own preset's sets. Photos
without a genre are counted in warnings; a group where none has one is default.
Every preset except default judges the moment: the stronger of the
preset's axis (cull_semantic_iqa: action, expression or candid) and, for
sports and event, an emotion question (cull_emotion_iqa). event and
portrait also ask whether closed eyes are a kiss or a laugh
(cull_eyes_closed_intent_iqa), which lifts the blink penalty. All three are
computed per request from the stored embedding against prompt vectors kept in
the database (below), and never stored themselves. Within a
group the moment applies only when every frame has it, and frames with a
defect no edit repairs (blur, unintended closed eyes, occlusion, focus that
missed the eyes) always rank below usable ones.
Focus on the subject (cull_subject_focus, cull_background_focus,
cull_background_streak, stored by the subject step of /v1/index/photos) acts only
when every frame of a group carries the current cull_subject_version (all or
nothing), and a frame without a subject abstains:
- in the score, as
subject_focus_weightof the sharpness the technical term reads, against the group like exposure (0.75;sportsandwildlife1.0); - a subject at
reason_subject_focus_thresholdand close to the group's sharpest is in focus, and then a soft frame-wide reading is not a blur defect — a shallow depth of field or a pan, as sharp eyes already settle it for a face; - below
reason_subject_focus_thresholdandrelative_subject_marginbehind the group's sharpest subject, the reasonsubject_out_of_focus, orfocus_on_backgroundwhen the background readsreason_background_sharper_marginsharper; belowreject_subject_focus_threshold, a clear defect. Never for a single; -
panningon a frame whose subject is in focus and whose background is streaked atreason_panning_streakor more;subject_sharpest_in_groupon a pick withinreason_sharpest_deltaof the sharpest subject.
Photos without a current reading are counted in warnings, with the fix ("Prepare
now", or "Download AI models" when the model is missing).
Eye and face focus (cull_eye_focus, cull_face_focus, stored by the
face pass of /v1/index/photos) are 1 - blur_effect (Crété-Roffet, as
skimage.measure.blur_effect) around the sharper eye of each face and over
the face box, measured on the full-size original rather than the working copy.
A photo's value is its weakest prominent face's; faces too small to measure
abstain. They act only when every frame of a group with a face carries
cull_focus_version (all or nothing, like the moment):
- in the score, as
eye_focus_weightof the face term, against the group like exposure; - as the reason
eyes_out_of_focus(orface_out_of_focuswhere the eyes were not measurable) belowreason_eye_focus_threshold, and as a clear defect belowreject_eye_focus_threshold— both only with a sibling to compare against and, under the relative rules, onlyrelative_focus_marginbehind the group's sharpest. A single is never rejected for its focus; - as
eyes_sharpest_in_groupon a pick withinreason_eye_focus_deltaof the best.
portrait weights and gates focus most, sports least, street not at all. Eyes measured
at or above reason_eye_focus_threshold also overrule a low frame-wide sharpness: such a
frame is not a blur defect.
Eye state, smile and gaze (cull_eye_closed, cull_smile, cull_eye_squint,
cull_gaze_off_lens) replace the eye-openness proxy where every frame of a group
with a face carries the current cull_face_state_version (all or nothing; an older
version counts as not analysed, and /index/unprocessed queues the photo again): the
blink penalty is then the model's eyeBlink of the more closed eye, judged at reason_eye_closed_threshold /
reject_eye_closed_threshold (plus the relative rules) instead of the proxy's
thresholds, and a face it could not judge is never a blink. Reasons:
eyes_closed, eyes_half_closed (a label, never a defect),
looking_away (portrait), looking_down (a note, and such a face is left out of
the blink reading), best_smile; a smile at
laugh_smile_threshold with the eyes shut counts as eyes_closed_intentional.
Mouth and the light on the face are labels only and move no score, until a
hand-culled shoot says what they are worth: mouth_open (portrait and event:
cull_mouth_open at reason_mouth_open_threshold, with a sibling clearly less
open), face_darker (the frame's darkest face at reason_face_darker_stops below the
group's brightest), face_hard_light (cull_face_across_stops at
reason_face_hard_light_stops, with a sibling clearly softer) and face_blown
(cull_face_blown at reason_face_blown_threshold, also on a single). street and
sports ask none of them. cull_face_luminance is only ever compared within a group,
since skin ranges over several stops between people.
smile_weight (portrait 0.15, event 0.10) takes a share of the face term,
against the group.
Response:
| Field | Type | Description |
|---|---|---|
summary |
object |
group_count, pick_count, alternate_count, reject_candidate_count, near_duplicate_group_count, intentional_set_group_count, intentional_set_photo_count, culling_preset, unindexed_count, selection — with a target, {"wanted", "selected_count", "scene_count", "highlight_photo_ids", "selected_with_defect"}, else null — presets — under auto, groups per chosen preset ({"sports": 12, "default": 4}), empty otherwise — and key_people: with key_people, {"named": [names], "unnamed": n, "groups": groups judged by them}, else null |
groups[].scene_id, groups[].selected_photo_ids
|
string, string[] | With a target: the group's scene (scene_0001, in time order) and which of its photos were chosen — none, or several. photos[].selected and photos[].highlight say the same per photo |
groups[].preset |
string | The preset the group was ranked with: the request's, or under auto the one its genre called for. Also in debug.culling_preset
|
groups[].winner_photo_id |
string or null | The best-ranked frame that is not a reject candidate. null when every frame is one: a blurred single, or a burst blurred throughout |
groups[].alternate_photo_ids, groups[].reject_candidate_photo_ids
|
string[] | The rest of the group |
groups[].photos[] |
object[] | Per photo: rank, cull_score, winner, reject_candidate, reason_codes, explanation, metrics. metrics carries semantic/semantic_axis, emotion/emotion_axis and moment only when computed, eye_focus/face_focus only when measured, eye_closed/smile/eye_squint/gaze_off_lens/mouth_open/face_luminance/face_across_stops/face_blown when the group was judged on the face state, eyes_closed_intended when true, and always clear_defect. Where at least two frames of the group had their eyes measured, explanation ends in eye focus N/10 within the group: 10 for the sharpest, one point less per 0.03 below it |
warnings |
string[] | Every caveat for the run, most fundamental first: unanalyzed photos skipped, no embeddings, AI signals whose prompt vectors could not be prepared (one message naming every lost signal and the fix), photos with faces prepared before eye focus or eye state were measured (one message when "Prepare now" fixes both), photos whose eyes the face state model could not analyse because it is not downloaded (with that fix), with a target fewer photos worth keeping than asked for and scenes kept with nothing but flawed frames, under auto photos without a genre, with key_people photos with faces never through face recognition and a selection where no one is a key person, photos whose subject was not measured, photos whose moment was not judged. Empty when there is nothing to say |
warning |
string or null | The same messages joined, for clients older than warnings
|
The aesthetic term of every preset is the stored AestheticSigLIP score: aesthetic_score of a
photo whose aesthetic_model is this build's, scaled (s - 1) / 8 to 0..1, injected as
cull_aesthetic_model (so it appears in stored_metadata) and blended with the heuristic at
aesthetic_model_weight (0.9, a chosen value rather than a tuned one). A score from another export
counts as missing. It needs no embedding and no text tower, so it is independent of use_iqa. The
number of ranked photos without one is said once in warnings, with the fix that fits: scoring them,
or downloading the model first.
Both routes return warnings, every caveat for the run as a list, and warning, the same
caveats joined into the one string the plugin shows (null when there are none). Besides photos
that were never analyzed and photos without embeddings, a caveat is raised when an AI signal the
run asked for — quality, moment, kiss gate — could not be computed: each compares a photo's stored
embedding against fixed prompt vectors, and those were neither in the database nor obtainable from
the text tower, almost always because the SigLIP2 model is not downloaded. The signals lost to that
one cause share one message, and ranking rests on the technical measurements alone.
The prompt vectors — the CLIP-IQA moment and emotion sets, the genre classifier, the culling kiss gate, the organism
gate /v1/index/photos uses in front of BioCLIP, the scene probes of /v1/edit/training and
/v1/edit/style — live in the text_embeddings table (model_id, text, vector). A request prepares the prompts it needs
once, before any per-photo work: from memory, else from the table for this build's
lrg_ml::siglip::MODEL_ID, else with the text tower, whose results are stored and which is unloaded
again straight away if the request loaded it. So each prompt is embedded once per model per
database, and in normal use only a free-text search or keyword clustering loads the text tower.
Only the prompts the request needs: on a CPU without fast fp16 one text-tower pass can take many
seconds, and a first cull must stay inside the plugin's request timeout.
Learns the photographer's culling style from their own decisions (lrg_analysis::taste). Body:
labels — [{"photo_id", "flag": -1|0|1, "rating": 0-5, "own": bool}], where own says the mark is
the photographer's rather than one a cull wrote and they left — and culling_preset (default
auto). The photos are ranked as /v1/cull/grade would rank them; inside every group with at
least one own mark, each two photos marked differently are a decision (pick over none over
reject, then more stars). A pairwise logistic regression without bias learns weights for the
ranked signals — cull_score itself, sharpness_effective, exposure, highlight_clip, noise,
aesthetic, face_score, eye_openness, eye_focus, eye_closed, smile, gaze_off_lens,
mouth_open, moment, subject_focus — on their differences, each scaled by its spread, and
five-fold cross-validation by group compares it with the default ranking on the same held-out
decisions. The model is stored next to the database (cull_taste.json) and returned as
{"model": {"pairs", "groups", "agreement", "default_agreement", "used"}}; used is true from
30 decisions and when it agrees more often than the default. Fewer than 30 decisions is a 422 with
error_code: "too_few_decisions", pairs and needed.
The learned style: {"model": {…}} as above, or {"model": null}.
Forgets it: {"deleted": true|false}.
With use_taste on /v1/cull/grade, a learned style that is used reorders each group's frames
that are not reject candidates by its score (a frame missing a signal counts the group's mean for
it); reject candidates keep their places after them, and a group whose pick changed gives the new
pick the reason preferred_by_your_style. summary.taste_changed_picks counts those groups. A
style not learned, or not better than the default, is said in warnings and changes nothing.
Generates a Lightroom develop recipe for a photo sent as a file upload, using an LLM.
No plugin workflow calls this any more. AI Edit Photos edits with a model through the retoucher loop (
POST /v1/edit/sessions), which looks at Lightroom's render of every edit instead of guessing once, and its "My style only" editor runs onPOST /v1/edit/stylewithout the LLM fallback. This endpoint and/v1/edit/recipe/base64are reachable only by direct HTTP callers. Both are fully supported;SearchIndexAPI.generateEditRecipePhotoin the plugin still speaks to/v1/edit/recipeand is simply not called.
Key request fields:
| Field | Type | Description |
|---|---|---|
photo_id |
string | Photo identifier |
provider |
string | LLM provider |
model |
string | Model name |
api_key, server_url
|
string | Connection fields, as for /v1/index/photos
|
temperature, reasoning_effort
|
float, string | As for /v1/index/photos, including the 400 for an unknown reasoning_effort. /v1/edit/style reads both for its LLM fallback |
intent |
string | Style preset key (e.g. natural_pro, moody_dramatic) |
style_strength |
float | 0.0–1.0, how aggressively to apply the style |
composition_mode |
string |
none, subtle, or aggressive
|
instruction_override |
string | Optional per-photo free-text instruction |
is_raw |
bool | Whether the original is a raw file. Optional; absent means unknown |
adjust_*, use_*, allow_auto_crop, include_masks
|
bool | Creative controls. Absent means enabled — an unchecked box must travel as an explicit "false"
|
is_raw cannot be recovered on the server: the plugin exports to JPEG before
uploading, so the original encoding is gone by the time the bytes arrive. It
decides two things:
- How far the guardrails let a recipe push. Raw files still hold detail behind clipped highlights; a rendered file does not.
-
The unit of
temperature. Lightroom's white balance is Kelvin (Temperature) for raw and a relative −100..100 offset (IncrementalTemperature) for JPEG/TIFF/PNG. The declared JSON schema follows the flag so the model answers in the right unit, and normalization clamps to the matching range. A Kelvin-looking value returned for a non-raw photo is dropped, not clamped — clamping 6200 into −100..100 yields +100, a hard orange cast — and the reason lands in the recipe's ownwarnings(edit.warnings, mirrored asedit_warnings).
Absent means unknown, which is treated as raw: that is what every catalog indexed before the flag existed assumed.
Response: A develop recipe object with global adjustments and an optional mask list, plus:
| Field | Type | Description |
|---|---|---|
guardrail_reasons |
string[] | Machine-readable codes (with the face and expression models on disk, also face_hard_light, face_below_surroundings, face_blown, read off the faces in the frame; if that read fails, warning says so and the edit goes on without them) for what the frame allowed or refused, e.g. hard_light_no_added_contrast, flat_light_contrast_allowed. Empty when the budget changed nothing |
guardrail_explanations |
string[] | The same, as sentences for the UI |
edit_warnings |
string[] | Unrelated problems worth surfacing (e.g. no training examples found) |
warnings |
string[] | The backend's non-fatal notes about this request, one entry each (on this route, the edit generator's warning). Always present, empty when there is nothing to say |
warning |
string |
Legacy. The same notes joined with \n, only when there are any; kept for installed plugins that read the single string. Remove once every supported plugin reads warnings
|
Before the recipe is returned, the backend decodes the image, measures the
scene (light hardness, dynamic range, specular fraction, shadow clipping),
derives a budget from it, and caps contrast-raising fields — contrast,
clarity, dehaze, the S-strength of the tone curve — plus shadows and
whites against it. The cap runs before the creative-control filter, so a
disabled field is never capped and then discarded.
Same as /v1/edit/recipe but accepts image data as base64.
Produces a recipe from the user's own saved edits with no LLM involved: it
retrieves training examples similar to the photo, re-scores them on exposure,
scene and time of day, and interpolates their develop settings. Needs at least
five stored examples. The result passes through the same guardrail budget as
/v1/edit/recipe and carries the same guardrail_reasons.
White balance is not blended like the other sliders. It is a mode plus a key
family — Lightroom's Temperature/Tint are Kelvin and tint on raw files,
IncrementalTemperature/IncrementalTint are −100..100 offsets on everything
else — so lrg_analysis::style_engine::blend_white_balance decides it
separately:
- The whole scored candidate pool (up to 20 examples, not only the three
blend winners) votes for a mode, weighted by composite score. Examples
without a
WhiteBalancedo not vote; ties go to the more conservative mode (As Shot, Auto, a preset, Custom). - As Shot wins: nothing is sent, the photo keeps its own. No warning — that is the habit the examples show.
-
Auto or a preset (Daylight, Cloudy, ...) wins: nothing is sent, with
a warning. For a non-raw photo always: Lightroom has no presets for it, and
in the first experiment run (Lightroom 15.6, run from the Library module)
Autoalone left the old numbers in the readback within 15 s (E1f/E1g; the Basic-panel check is open). For a raw photo whileEMIT_NAMED_WB_MODESisfalse, until experiment E1 shows that Lightroom recomputes the temperature for a mode set without numbers on a raw file (E1e has not run); turning it on reaches raw photos only. - Custom wins: temperature and tint from up to 3 of the best-scored Custom examples of the photo's own family, weighted over exactly those, and only when at least 2 exist and their temperatures lie within 500 K (20 for non-raw offsets). Otherwise nothing is sent, with a warning.
- Raw examples give Kelvin only to raw photos, non-raw examples offsets only to
non-raw photos; an unknown
is_rawsends nothing. Every "nothing is sent" the user could act on comes back as an entry inwarnings.
Each warnings text is constant per cause: the per-photo numbers (the match
confidence, the measured temperature spread) stay in confidence, the recipe
summary and the log. The plugin merges warnings by exact text into one
"(N photos)" line per run, and a number in the text would make every photo its
own line. Style-path notes: a low or moderate match confidence, each white
balance that was not transferred, and matching examples that taught nothing
(re-read rows older than PV2012 or with unreadable settings).
The result travels at recipe level, next to global and masks, never as
global.temperature/global.tint and never as global.white_balance (that
name is the LLM schema's {temperature, tint} alias):
"edit": {
"summary": "...",
"global": {
"exposure": 0.35, "contrast": 12, "sharpen_radius": 1.2, "grain_size": 30,
"hsl": {"blue": {"hue": -5, "saturation": -20, "luminance": -12}, "...": {}},
"color_grading": {"shadows": {"hue": 220, "saturation": 15},
"highlights": {"hue": 40, "saturation": 20}, "balance": -10},
"tone_curve": {"shadows": -8, "shadow_split": 20, "midtone_split": 45, "highlight_split": 70}
},
"masks": [],
"warnings": [],
"white_balance": {"mode": "Custom", "family": "raw", "temperature": 5450, "tint": 10}
}family is "raw" or "non_raw"; temperature/tint are in that family's
keys' units and precision. Plugins that predate the field ignore it (they read
only summary/global/masks/warnings). The response also carries the
top-level warnings list and the legacy joined warning string described under
POST /v1/edit/recipe; a JPEG target with raw examples gets, for example,
"White balance was not transferred: your training examples are raw files and
this photo is not ...". The thresholds are assumptions pinned by tests
(style_engine.rs), and the transfer is not switchable in this release.
The other settings are blended per key over the winners that carry the key (an
example that never touched a slider does not pull it towards 0) and rounded to
the key's registry precision (integers for integer sliders, Exposure2012 to 2
decimals, SharpenRadius to 1). The learned keys are the registry's global.*
recipe aliases: every global field the installed plugin's
DevelopEditManager already applies (65 keys since step 1f):
global field(s) |
Lightroom keys |
|---|---|
exposure, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze, vibrance, saturation
|
Exposure2012, Contrast2012, ..., Saturation
|
sharpening, sharpen_radius, sharpen_detail, sharpen_masking
|
Sharpness, SharpenRadius, SharpenDetail, SharpenEdgeMasking
|
noise_reduction, noise_reduction_detail, noise_reduction_contrast
|
LuminanceSmoothing, LuminanceNoiseReductionDetail, LuminanceNoiseReductionContrast
|
color_noise_reduction, color_noise_reduction_detail, color_noise_reduction_smoothness
|
ColorNoiseReduction, ColorNoiseReductionDetail, ColorNoiseReductionSmoothness
|
vignette, vignette_midpoint, vignette_roundness, vignette_feather, vignette_highlights
|
PostCropVignetteAmount, ...Midpoint, ...Roundness, ...Feather, ...HighlightContrast
|
grain, grain_size, grain_roughness
|
GrainAmount, GrainSize, GrainFrequency
|
hsl.<red..magenta>.{hue,saturation,luminance} |
HueAdjustment<Colour>, SaturationAdjustment<Colour>, LuminanceAdjustment<Colour>
|
color_grading.{shadows,highlights}.{hue,saturation}, color_grading.balance
|
SplitToning{Shadow,Highlight}{Hue,Saturation}, SplitToningBalance
|
tone_curve.{highlights,lights,darks,shadows}, tone_curve.{shadow,midtone,highlight}_split
|
Parametric* |
Not sent: colour-grading midtones, global, luminance and blending (installed
plugins warn "not supported" and drop them), point curves (averaging curves is
a later step), lens, crop, Look, masks.
Two kinds of key are not plain means:
-
Toning (
SplitToning{Shadow,Highlight}{Hue,Saturation}; registry gateCircularHue(<saturation key>)on the hue): each winner's zone is a colour vector (hue = angle, saturation = length), and the zone gets the score-weighted mean vector: its angle is the hue, its length the saturation. So 350° and 10° give 0°, a hue left behind at saturation 0 does not pull, and tints that disagree fade towards neutral instead of landing at full strength on a hue none of the examples used (orange 30°/40 and blue 190°/40 give 110°/7, not 110°/40). Where the tints agree the saturation equals the plain mean. If the saturations sum to about 0, or the mean vector rounds to saturation 0 (fully cancelling tints), the hue is left out and saturation 0 is sent. When the toned winners' agreement (mean resultant length) is belowMIN_HUE_AGREEMENT(0.5, i.e. the tint lost more than half its strength) and the plain mean saturation is at least 5,warningsgets "Color grading of the shadows was toned down: your matching examples tint the shadows in clearly different colors." (or highlights). -
Curve splits are blended as one group over the winners that carry all
three, which keeps
shadow_split < midtone_split < highlight_split; if integer rounding makes two equal, the three are left out andwarningsgets "The tone curve's region splits were not transferred: your matching examples' splits could not be averaged into a valid order."
Black-and-white examples (ConvertToGrayscale) contribute no toning and no
colour mixer: the recipe does not carry the B&W switch, so their sepia or
selenium toning would land on colour photos as a cast. The Basic, curve,
detail and effects keys they carry are still blended.
HueAdjustment* (the colour mixer's hue) is an offset on −100..100, not an
angle, so it is a plain mean.
The guardrail budget (edit_budget::measure_and_apply) acts on contrast,
clarity, dehaze, the parametric curve strength, shadows and whites only. The
new keys need no budget of their own: the splits do not change the curve
strength (a scaled curve keeps them), and HSL saturation, vignette and grain
are not moves the frame measurements (light hardness, dynamic range, clipping)
say anything about — like the global saturation/vibrance, which were never
budgeted.
Stored examples carry a canonical_version (currently 3). A row written by an
older backend (no version, or a lower one) is re-read from its stored
develop_settings blob when it is retrieved — at most CANDIDATE_POOL rows per
request, nothing is written back — so examples saved before white balance was
read from the right keys contribute it now, and examples saved before step 1f
contribute the colour mixer, toning, curve splits and detail/effects sliders. A re-read row that teaches nothing (settings older than
PV2012, or a blob that cannot be read) is counted, and the response gets one
warnings entry for it.
Only examples embedded with the current search model are compared with the photo
(embedding_model in the example's metadata, written by POST /v1/edit/training); a vector
from another model means nothing next to this one. Examples saved before the identity was
recorded count as outdated. When every stored example is outdated, the endpoint says so
(error on the 422, or style_engine_note when the LLM fallback runs) instead of reporting
that there is nothing stored, and /v1/edit/recipe skips style matching with the same
explanation in warning. Saving an example again replaces it.
The /v1/edit/recipe few-shot path keeps the two scales apart too: an example
whose raw status conflicts with the target has its whole white-balance family
(Temperature, Tint, IncrementalTemperature, IncrementalTint,
WhiteBalance) stripped before it reaches the prompt, so the model is never
anchored on a number the schema it must answer in cannot express.
The aesthetic score of one rendered image, and nothing else: nothing is stored and no
photo record is touched. Multipart with a single image part (any format the image
crate decodes; the plugin sends the JPEG it exports). Answers
{"status": "success", "score": 5.87, "model": "<aesthetic model id>"}, the score on the
scorer's 1–9 scale rounded to two decimals — the same figure POST /v1/index/photos
stores for a photo.
It exists for AI Edit's optional refinement loop (AestheticRefine.lua), which applies a
candidate edit to a photo, renders it and asks what the scorer makes of it, several times
per photo. The score of an intermediate render belongs to no photo, which is why this is
not an indexing task.
| Status | Body | When |
|---|---|---|
| 400 | {"status": "error", "error": ...} |
No image part, or an empty one |
| 503 | {"status": "error", "error": ...} |
The aesthetic model is not downloaded. The message is the instruction ("run "Download AI models" in Plug-in Manager") and is shown as it stands |
| 500 | {"status": "error", "error": ...} |
The image could not be decoded, or the model failed |
Opens an AI Edit retoucher session for one photo and answers with its first
step. The retoucher is a loop: a vision model edits, Lightroom applies and renders
the edit, the model sees the render with its measurements and corrects, until it
calls finish or runs out of edits. Lightroom has to sit between the rounds, so
the backend keeps the conversation in memory (lrg_api::edit_loop) and the plugin
drives it (EditLoop.lua): apply the step's table, render, upload the render to
…/render, repeat. Sessions nobody touches for 30 minutes are dropped.
Multipart:
| Field | Type | Description |
|---|---|---|
photo_id, image
|
string, file | The photo, rendered as it is now (the plugin sends 1536 px on the long edge; the model sees at most that) |
provider, model
|
string | Any provider the agent chat runs on: llamacpp, mlx, ollama, lmstudio, openai_compatible, chatgpt, gemini, anthropic. The model must take images and call tools |
api_key, server_url, ollama_base_url, lmstudio_base_url, llm_*
|
string | Connection and local-engine fields, as for /v1/index/photos
|
develop_settings |
JSON |
photo:getDevelopSettings() before the loop. Tells the model where every slider starts, decides the white-balance family (raw: Kelvin Temperature; rendered: IncrementalTemperature) and the process version the table is written for |
is_raw |
bool | Only a fallback, when the settings carry no white-balance keys |
look |
string |
natural (default), bright_airy, moody, cinematic, film, punchy, black_and_white (lrg_providers::edit_loop::LOOKS) |
instructions |
string | The user's own words; they win over the look |
language |
string | Language of the model's assessment and verdict
|
max_edits |
int | Edits the model may apply, 1–5 (default 3) |
masks |
bool | Offer AI masks (subject, sky, background, people parts, landscape classes). The plugin sends true only on Lightroom 15.3+, where photo:updateAISettings() computes them |
style_recipe |
JSON | The edit of a /v1/edit/style answer. When set, the photographer's style is round 0: it comes back as the first step without asking the model, and the model's first look is at the original and the style's render with its values, told to keep the style and fix the frame. Every edit the model sends is merged onto the style (lrg_providers::edit_loop::merge_onto): what it leaves out keeps the style's value, so the steps' tables carry the whole style plus the model's changes |
db_path, catalog_id
|
string | As elsewhere; the finished recipe is stored on the photo's record |
Each step:
| Field | Type | Description |
|---|---|---|
session_id |
string | For …/render and DELETE
|
round |
int | 0 for the style, then 1, 2, … |
source |
string |
"style" or "model"
|
done |
bool | The session has finished; keep_round says which round to leave on the photo |
develop_settings |
object | The table for photo:applyDevelopSettings(), written by lrg-develop's Lua writer: the complete edit measured from the starting point, not a change on top of the previous round. Masks are MaskGroupBasedCorrections entries named LrGenius · <target> with sync ids derived from the target, so a later round's mask replaces an earlier one. Empty on a done step |
update_ai_settings |
bool | Call photo:updateAISettings() after applying (the table has AI masks) |
edit |
object | The recipe in the model's vocabulary (UI units, after the guardrails), for display and the photo's record |
assessment |
string | What the model saw and changed this round |
verdict, keep_round
|
string, int | On the done step: the model's last word, and the round to keep (the latest unless it named an earlier, better one) |
edits_left |
int | |
warnings |
string[] | What did not reach the photo as given: a value Lightroom's range clamped, a mask target that does not exist, masks beyond the six an edit may carry, a key the photo's process version lacks, the style's white balance from the other file kind. The guardrail explanations are not repeated here (they are in guardrail_explanations); the model gets both with the render |
guardrail_reasons, guardrail_explanations
|
string[] | As for /v1/edit/recipe: every round's recipe passes the same budget, measured on the original upload |
input_tokens, output_tokens
|
int | So far in the session |
| Status | When |
|---|---|
| 400 | No photo or image, unreadable develop_settings/style_recipe, no model chosen, a model that cannot see images, a missing key or server address |
| 502 | The model call failed, or three answers brought no usable edit. The message names the cause: no tool call at all (a model without tool calling), or the reasons Lightroom could not use the edits it sent |
The render of the last step's edit (multipart image), plus optional
lightroom_notes, a JSON list of sentences about what Lightroom saw while applying
it: an AI mask that found nothing, one not computed within 30 s, or a failed
updateAISettings() (the plugin also shows these to the user when they concern the
round the photo ends on). The model gets the render,
the values of the edit it shows (recipe_text, so a model refining its edit has the
numbers in front of it), its measurements (lrg_imaging::tone: luminance
percentiles, histogram bands, clipping, the colour of the near-neutral tones,
midtone saturation), how they moved against the original, and what did not reach
the photo; it answers with the next
step in the shape above. A done step ends the session and stores the kept recipe
on the photo's record, as /v1/edit/style stores the recipe it generates: what the
model made of the photo, whether or not the user then keeps it in the plugin's
review. When that store fails, the step's warnings say so. 404 when the session
has ended or expired; 502 as for the start.
Ends a session early (the plugin does this when a run is canceled or breaks off).
{"status": "success", "ended": true|false}; ending one that already ended is not
an error.
Two metadata fields on a face row carry a person, and the difference between them decides what a re-cluster is allowed to touch:
-
person_id— who the face belongs to. Empty orperson_unassignedmeans nobody. -
person_manual— true when a human said so on the People page. A guess the clusterer may revise versus a fact it must preserve. Every editing endpoint below sets it,POST /v1/faces/clusterhonours it, and re-detection during indexing carries it over.
Detects faces in an uploaded image and stores their embeddings.
Finds photos containing faces similar to those in a reference image. Only
stored faces from the recognizer that embedded the query (face_model) are
ranked: after a FaceNet/AuraFace switch the other rows are vectors of another
space until their photos are re-indexed. How many were left out comes back in
warnings, so a search right after a switch (no results at all) says why
instead of looking like nobody matched.
Which network makes face identity embeddings (lrg_ml::faces::Recognizer):
{"active": "facenet", "saved": "facenet", "model_id": "yunet-2023mar/facenet-vggface2",
"recognizers": [{"id": "facenet", "name": "FaceNet", "description": "...", "ready": true,
"active": true, "model_id": "...", "approx_bytes": 0, "downloadable": false},
{"id": "auraface", "name": "AuraFace", "ready": false, "downloadable": true,
"approx_bytes": 261000000, ...}],
"warnings": []}saved differs from active when the saved choice could not be restored at
start (AuraFace's file is gone); warnings then says so, for the Plug-in
Manager.
{"recognizer": "facenet" | "auraface"} — switches, saves the choice (restored
at start) and answers like GET, plus changed and warnings for the user
(the faces of the other recognizer are set aside until re-indexed; names carry
over). 400 for an unknown name; 409 error_code: "not_downloaded" when the
recognizer's model is not on disk.
The switch changes face_model on every row written afterwards. Clustering,
/v1/faces/search and /v1/index/unprocessed only treat rows of the active
model as current, so the two spaces are never compared. On re-detection
(POST /v1/index/photos with faces), person assignments of previous rows from
the other recognizer are carried over by box overlap (IoU ≥ 0.6) — the
detector is the same, so a face lands on its old box — while rows of the same
recognizer keep matching by embedding.
Starts the AuraFace download (fal/AuraFace-v1, glintr100.onnx, from
Hugging Face — HF_ENDPOINT is honoured) / reports its progress, in the same
shape as /v1/models/assets/downloads (status, progress, total,
current_file, error). Does not switch; the plugin calls
POST /v1/faces/recognizer once it completed.
Re-clusters all stored face embeddings into person groups. The policy lives in
lrg_analysis::face_cluster; this endpoint is the store side of it.
Request fields (all optional): distance_threshold (cosine, default 0.5),
min_faces_per_person (default 2, null keeps singletons),
linkage ("complete" default, or "average"), min_det_score,
min_area_ratio, min_sharpness, max_occlusion, attach_factor,
ignore_manual, and algorithm.
What the default path does, and why:
-
Quality gate first. A blurred, tiny, or half-occluded face is the one
that sits between two people and welds them together. Faces below the gate
may not form a person; after clustering they are attached to the nearest
finished cluster whose centroid is within
distance_threshold × attach_factor(0.9), or left unassigned. A face row indexed before this version measured quality has no scores at all — an unmeasured field never rejects, and the response warns that those rows were passed through ungated. -
Complete linkage, not DBSCAN. DBSCAN's density connectivity is
transitive, so a single borderline face chains two people into one cluster.
Complete linkage requires every pair across two clusters to be within the
threshold. Passing
"algorithm": "dbscan"restores the old behaviour and always returns a warning saying it chains. -
Manual assignments are pinned. Faces with
person_manualenter as pre-formed seeds that keep theirperson_id, cannot be split, and cannot merge with a different pinned person."ignore_manual": trueopts out. -
Canopy pre-partition above
AGGLOMERATIVE_MAX_POINTS. The agglomerative n×n distance matrix would be gigabytes, so a greedy centroid pass splits the faces into canopies at a loose threshold and each is clustered separately. This is reported indiagnostics.canopiesand warned about, because a canopy boundary can split one person.
Faces whose row carries no usable embedding, or whose embedding came from a superseded face model, are reported as unassigned rather than clustered — an empty vector compares as distance 0 to everything and would merge unrelated clusters.
The response carries person_count, warnings, and a diagnostics object —
threshold, algorithm, clusters, pinned_faces, low_quality,
unmeasured, attached, dropped_small, canopies, mean_spread and
near_duplicates — how many clusters have another cluster within 1.2× the
threshold, i.e. how many a slightly looser run would have merged. The last two
are what makes the threshold judgeable: a high near_duplicates means one
person is probably split across several clusters, and a mean_spread
approaching the threshold means clusters are being stretched over more than one
face.
Moves specific faces to a person. Body: face_ids (required), plus either
person_id, or "new_person": true to allocate the next free id, optionally
with a name. Assigning to person_unassigned detaches the faces from
everyone. Every touched row gets person_manual: true, so the next
Cluster faces run preserves the decision. Face ids that no longer exist are
returned as warnings rather than failing the request.
Returns all detected persons with person_id, name, face_count,
photo_count, manual_count (how many of the faces a human placed) and a
representative thumbnail (base64 JPEG, empty when the person has none). The
representative is the person's best portrait (lrg_analysis::face_expression::portrait_score):
detector confidence and sharpness, less a shut eye, a turned head and a gaze off
the lens, plus a little for a smile. A row without a reading pays a fixed price, so
it yields to a portrait known to be good.
Every face without a person — an empty person_id from a row that was never
clustered, or person_unassigned written by the clusterer — is reported as a
single entry with an empty person_id. The thumbnail is included here on
purpose: fetching it per person from /v1/faces/persons/<id>/thumbnail meant one
full table scan per person.
Merges two or more persons into one. Body: person_ids (at least two distinct
real persons — the unassigned pseudo-person is not one), optional into (the
survivor; defaults to whichever has the most faces) and optional name.
Every face of every person involved — including the target's own — is rewritten
to the surviving id with person_manual: true, so the merge holds through the
next re-cluster. The name is resolved in order: an explicit name, else the
target's existing name, else any name from a merged-away person. The merged-away
ids are removed from the names file.
Returns the representative face thumbnail for a person as base64 JPEG. Kept for
older plugin builds; GET /v1/faces/persons already includes it, and this scans
the whole face table for one image, so it must not be called in a loop.
Names a person cluster, and by naming it confirms it. Body: name (empty or
absent clears it).
Every face of that person is marked person_manual: true, exactly as a merge
does, and the count comes back as confirmed. Naming is the user's claim that
the cluster really is one person, so the next Cluster faces run has to keep
it together — without the pin the name could end up on a different set of faces.
Clearing the name pins nothing (confirmed: 0) and unpins nothing: the same
flag also records hand-assignments and merges, and no row says which of those
wrote it, so undoing the naming would throw away decisions it never made.
Naming the unassigned pseudo-person pins nothing either. If the name saves but
the pinning fails, the request still succeeds and says so in warnings.
The People page uses this endpoint's name collision — a name another person
already has — as its merge gesture: it asks, then either posts to
/v1/faces/persons/merge or sends the name anyway when the user says the two
really are different people with the same name.
The ids of the photos showing this person, in photo_ids (photo_uuids is the same list). _unassigned opens the faces without a person.
| Query | Meaning |
|---|---|
eyes_open, smiling, looking_at_camera
|
true keeps only photos where this person's face answers yes (lrg_analysis::face_expression). A person twice on a photo counts when either face does |
sort |
best puts the person's best portrait first; otherwise by photo id |
limit |
At most this many |
not_judged counts the photos left out because the face has no reading, and warnings says so with the fix, since that is a problem the user can solve, where a frown is simply not a smile.
Returns the individual faces in one person, which is what the People page's
detail view is built on. Query parameters: limit (default 200, max 500),
offset, and sort — typical (closest to the cluster centroid first),
outlier (furthest first, which surfaces wrong faces), quality or portrait (the
best picture of them first, as for the person's card).
Only this person's vectors are fetched, so the endpoint is cheap enough to page
through. Each face carries face_id, photo_id, thumbnail, distance from
the cluster centroid, manual, the four quality measurements
(det_score, area_ratio, sharpness, occlusion) and a quality_note when
one of them is below the gate. The envelope adds total, offset, limit,
returned, name and spread (mean distance to the centroid).
distance is what lets a threshold be judged by eye rather than guessed: a
face further from the centroid than the clustering threshold is drawn as an
outlier on the page.
Pages the plugin opens in the browser instead of rebuilding them out of
Lightroom's view factory, plus the queue they talk back through. The pages are
compiled into the binary from crates/lrg-api/src/ui/, so they can never go
stale against the server serving them.
A page can call the rest of this API directly — it is served from the
backend's own origin, so no CORS setup is involved — and it forwards the
db_path the plugin put in its URL on every request, which the auto-bind
middleware honours exactly like the plugin's own calls. It sends the access
token (see Access) the plugin put in the URL's #fragment, which
the browser sends nowhere; the page takes it out of the address bar and keeps
it in sessionStorage for reloads.
What a page cannot do is touch the Lightroom catalog. Those actions are queued instead: the page POSTs one, and the plugin task that is waiting on it drains the queue and performs it. Both ends stamp when they were last heard from, so each can tell the user the other one is gone instead of appearing to hang.
Queue, stamps and context are kept per page — people or search, named
by a page query parameter (status, the plugin's poll) or body field (an
action) — because Lightroom runs plug-in tasks side by side: with one shared
queue the People task would drain, and refuse as unknown, what the search page
queued for its own task. A request that names no page means people, which is
what every client sent before there was a second page; an unknown page is a
400 (ui_bridge::PAGES).
Serves the People page: person grid, renaming, re-clustering, filtering, and the selection that becomes a Lightroom collection. Replaced the plugin's LrView People dialog.
Serves the browser search page (src/ui/search.html, opened by
TaskSearchBrowser.lua — Search (browser)... in the Library and File menus).
It runs the same POST /v1/search as the Advanced Search dialog, again as the
options change (typing, debounced; sliders and menus at once, an older request
cancelled by the newer one): term with the search history, scope (all indexed
photos, current view, selection), sort, minimum aesthetic score, genre,
relevance strictness, max results and the metadata half. No ranking of its own:
what the grid shows is the route's answer, in its order, with its warnings
in a banner. Each result shows its stored preview (GET /v1/photos/thumbnail),
or a placeholder when has_thumbnail is false, and the number of those comes
with what to run to add them. Show in Lightroom queues
show_search_results with the result IDs in rank order.
Sent like the chat page, with a Content-Security-Policy (default-src 'none',
connect-src 'self', img-src 'self' blob: data:, frame-ancestors 'none'),
Cache-Control: no-store and Referrer-Policy: no-referrer: unlike the People
page it shows pictures from the catalog and keeps the access token in
sessionStorage.
What the search page needs and only Lightroom knows. The plugin task POSTs it
before it opens the page and again for each refresh_search_context action
(the page's Update from Lightroom); the page GETs it. Stored as sent (a JSON
object, else 400), with a revision raised by every POST, so a page waiting
for a refresh can tell the new context from the old. A POST also counts as the
plugin being there.
{
"catalog_id": "…",
"scopes": {
"view": { "label": "2026-10, Bavaria", "photo_ids": ["…"], "unresolved": 0,
"whole_catalog": true, "count": 14000 },
"selection": { "photo_ids": ["…"], "unresolved": 1 }
},
"defaults": { "scope": "all", "relevance_strictness": 50, "max_results": 300, "sort": "relevance",
"min_aesthetic_position": 0, "genre": "all", "metadata": true },
"history": ["heron", "…"],
"notices": ["12 of 14000 photos have no search index yet …"]
}view.whole_catalog stands for All Photographs, sent without IDs because it is
the same search as all indexed photos (resolving every photo's ID first would
take minutes). unresolved counts photos whose ID could not be worked out (an
offline file) and that are therefore not in the scope; the page says so.
defaults are the Advanced Search dialog's saved settings; notices are what
that dialog says before it opens — no search model, photos never indexed or
indexed with the previous model. catalog_id absent means searches cannot be
limited to this catalog, which the page says too.
GET returns {context, revision, plugin_connected}, context null and
revision 0 until the plugin has posted one.
Page heartbeat, ?page=search for the search page. Returns plugin_connected
— false once that page's plugin task has not polled for 5 seconds, which is how
the page reports that People (or Search) was closed in Lightroom instead of
silently queueing work nobody will run.
Plugin poll, ?page=search for the search task. Returns every queued action of
that page and clears its queue, so each action is handed out exactly once — a
caller that drops the response drops the work. Also returns page_open, false
when that page has not been seen for 90 seconds — wide enough that a page
throttled in a hidden browser tab still counts — which lets the plugin report a
browser that never opened.
Queues one action for the plugin task of the body's page. The action has to
be one that page's task knows; anything else is a 400 rather than an entry
nothing will ever run:
| page | action | needs |
|---|---|---|
people |
show_in_library |
a non-empty person_ids array; eyes_open, smiling, looking_at_camera, best are passed on to GET /v1/faces/persons/<person_id>/photos
|
search |
show_search_results |
a non-empty photo_ids array of strings, in rank order; term, sort, min_aesthetic, genre_label, max_results name the collection |
search |
refresh_search_context |
nothing |
Returns plugin_connected as it was before the enqueue — the answer to "will
anything pick this up".
show_search_results becomes a collection under Search Results, named like
the Advanced Search dialog's, with the photos in the page's rank order.
Results the catalog does not have are counted in a warning: with the search
limited to this catalog they are photos deleted since they were indexed, and the
index cleanup is started; without a catalog ID they may belong to another
catalog on the same backend.
An AI agent — Claude Code, Claude Desktop, LM Studio, Cursor, any MCP client —
connects to /mcp and gets tools over LrGeniusAI's index (search, people,
culling) and over Lightroom itself (catalog queries, previews, metadata,
collections, develop settings, the plugin's own tests). The backend cannot
touch the catalog, so the Lightroom tools queue commands on the agent
bridge and the plugin carries them out. Full design, the command list and how
to test it: Dev: Agent Bridge. For users:
Help: AI Agents.
These routes refuse anything a web page could send: a request with an Origin
header (a browser — Lightroom's LrHttp sends none) or a Host that is not
this computer (a DNS-rebinding page) gets a 403. All but registration and the
status read also need the session token from registration, in the
X-LrGenius-Bridge-Token header; an unknown token gets a 401 with
reregister: true, which is how the plugin notices a backend restart.
The MCP endpoint (Streamable HTTP, served by lrg-mcp and mounted next to
these routes rather than under /v1 — MCP negotiates its own version). POST
carries JSON-RPC messages and answers as JSON or as an event stream (tool
progress arrives as notifications/progress on it); GET opens the
server-to-client stream; DELETE ends the session named in Mcp-Session-Id.
Only answers when Host is this computer, rejects every browser Origin, and
needs the agent token as Authorization: Bearer <token>. A missing or wrong
token gets a 403 with a JSON-RPC error saying where to copy the setup, and so
does every request while no plugin session is registered. geniusai-server mcp is a stdio proxy to it for clients that only start commands (Claude
Desktop); it takes the token from LRGENIUS_MCP_TOKEN or --token.
The plugin registers. Body: port (its LrSocket doorbell; absent when the
socket could not be opened — the plugin then polls), previous_token (keeps an
existing session when only the port changed), and what it reports about itself:
plugin_version, lightroom_version, catalog_path, catalog_name,
catalog_id, db_path, allow_changes, dev_tools, platform, plus
mcp_token (the agent token it keeps; absent on its first registration),
rotate_mcp_token (make a new one) and started_at (when this bridge loop
started). A registration whose started_at is older than the newest one seen
since the backend started gets 409 with superseded: true; the plugin's
loop then stops. That ends the fight between the old and the new loop after
"Reload Plug-in". Returns token, session, resumed,
ping_interval_seconds and mcp_token: the agent token /mcp now requires,
newly made when the plugin had none, asked for a new one, or sent something
too short to be a secret. The backend then
connects to the port and writes wake per queued command and ping on that
interval. Any other registration replaces the old session; its unfinished
commands fail as "plugin reloaded".
Bridge status: connected, socket (none/connecting/connected/down),
plugin (as registered), registered_at, last_seen_seconds, queued,
in_progress, commands_done. Never includes the token.
The plugin stops on purpose (the user cancelled the bridge, or Lightroom is quitting). Pending commands fail with "the user stopped agent control" so a waiting agent stops instead of retrying.
The plugin claims every queued command, oldest first: commands is a list of
{id, action, args, timeout_seconds}. Claiming also counts as the plugin being
alive while its socket is down.
The plugin's answer: ok, result, error, warnings (always passed on to
the agent, also on success), and refused ("changes" or "dev_tools" when
the plugin's settings forbid the command). Returns accepted: false when the
caller already gave up (timeout or cancellation).
{done, total, message} while a long command runs; forwarded to the MCP client
as progress notifications. The answer's cancel is true once nobody waits for
the command any more, and the plugin stops between photos.
The in-app chat: a local or cloud AI model works the catalog through the same tools as
/mcp, and the user chats with it — and approves its changes — in the
browser. Design, the permission model and the event list:
Dev: Agent Chat. Served by lrg-chat, merged next to /mcp
and written as served (not nested by build_router).
Opening a chat takes the agent token as Authorization: Bearer <token>, like
/mcp. Every other route takes the chat's own token the same way, and refuses
a request whose Origin is not this server even with the right token. A wrong
token, a foreign origin and a chat that has ended all get the same 403.
Errors are {"error": "<what to do>"}.
The chat page (lrg-chat/src/ui/chat.html), no token needed: it is the same
for everyone and holds nothing until it is given a chat. It reads session and
token from its fragment, keeps them in sessionStorage so a reload works,
and removes them from the address bar. Sent with a strict
Content-Security-Policy (default-src 'none', connect-src 'self',
frame-ancestors 'none'), Cache-Control: no-store and
Referrer-Policy: no-referrer. A new chat's address opened in the same tab
changes only the fragment; the page reloads itself for it.
Opens a chat. Body: provider and model as for indexing, and what that
provider needs:
-
llamacpp,mlx:llm_n_ctx,llm_n_parallel,llm_gpu_layersas on the indexing routes. The model loads in the background; one that cannot load is reported as anerrorevent. -
chatgpt,anthropic,gemini:api_key, required. -
openai_compatible:server_url, required, andapi_keyif the server wants one.lmstudio,ollama: optionallmstudio_base_url,ollama_base_url.
connections, optional: every key and address the user has set up, under the
names POST /v1/llm/providers/models takes
(openai_apikey, gemini_apikey, anthropic_apikey, server_url,
server_apikey, …), so the page can offer every provider in its model menu.
The fields above win for the chosen provider. The engine settings are kept as
well, for a switch to a local model.
The keys are kept in the chat's memory only — never logged, saved or returned.
A missing key, address or model is a 400 naming what to set. Returns
session_id, token and
page, the path to open with the token in its fragment
(/v1/ui/chat#session=…&token=…). 503 when 8 chats are open and none is
idle enough to close. No plugin session registered: 403 saying to turn the
agent bridge on.
GET returns what the page draws from: model, busy, tools (each with
name, title, class — read/change/destructive — and its rule),
permissions, permissions_saved (false while no index is bound, so a change
would not survive a restart), sends_to (who receives the conversation when
that is not this computer — "Anthropic", "your AI server (host:port)" — or
null) and lightroom (catalog_name,
may_change_photos; null when the plugin is not connected). DELETE stops the
chat and forgets it (204). A chat nobody has listened to for two hours is
closed by the backend.
Server-sent events, one JSON object per data: line, with a type and — for
everything except the streamed delta pieces — a seq. ?after=<seq>
replays what was logged after it, then continues live. A page that falls too
far behind gets event: resync and the stream ends; reconnecting with after
catches up. The event types are listed in Dev: Agent Chat.
{"text": "…"}. 202 and the work starts; its progress arrives on the event
stream. 409 while the previous message is still being worked on, 400
for an empty one.
The answer to an approval_needed event: {"decision": "once"}, "chat"
(this tool for the rest of the chat), "always" (sets the tool's rule to
allow), or {"decision": "deny", "note": "…"} — the note is passed to the
model. 204; 409 when that call is not waiting any more.
Stops after the step in progress: the model's turn is cancelled, calls waiting
for approval are denied, nothing new starts. A tool already running in
Lightroom finishes and its result is recorded. The event stream gets a
stopped event, and the model is told in the history that the user stopped
there. 204.
What the page's model menu offers, probed with the chat's connections the
way POST /v1/llm/providers/models probes: current (provider, model,
label — the model this chat runs on, an old MLX name mapped to the one it is
listed under), providers (in the plugin's picker order, each with
provider, label — "On this Mac", "LM Studio", the AI server's name, … —,
sends_to as on GET /v1/chat/sessions/{id}, and models, sorted), and
warnings (an AI server or key the user set up that did not answer). A
provider with nothing to offer — not running, no key — is left out.
{"provider": "…", "model": "…"}: start over on another model. A conversation
is not carried from one model to another: a new chat with the same connections
and engine settings, and nothing in it, replaces this one, which ends (its
token gets 403 from then on). Returns what POST /v1/chat/sessions does —
the page opens the new page. 400 names a missing key, address or model
(this chat stays as it was); 409 while a message is being worked on.
What runs without asking: mode (ask_all, auto_read — the default —,
auto_safe, auto_all), tools (tool name → allow, ask or block) and
bulk_limit (a change to photos aimed at more than this many asks even when
allowed; 0 turns it off). The settings are the user's, not the chat's: every
chat uses them, and they are saved as chat_permissions.json beside the index.
PUT takes the whole object and returns {saved, permissions}.
Imports existing Lightroom catalog metadata (title, caption, alt text and keywords) into the backend database for a batch of photos.
metadata_items[].keywords is the photo's keyword hierarchy
(MetadataManager.getPhotoKeywordHierarchy) without the plugin's top-level
container keyword ("LrGeniusAI", "ChatGPT", ...). Analysis results have no such
root either, and "Retrieve Metadata" writes a stored hierarchy back underneath
the container, so a root left in would come back nested inside itself.
Synchronous keyword clustering: groups catalog keywords by semantic similarity.
Async version: starts a background clustering job. Returns 202 with a
job_id and the poll_url to read it back from — see GET /v1/jobs/<job_id>.
Long-running work is enqueued by the domain that knows what to start, and read
back from one place. Every enqueue response carries a job_id and a poll_url
pointing here, so the poll location is never derived from the enqueue path.
Returns {status, result, error, progress}, where status is running,
done or error.
A finished job is returned once and then dropped from the registry, and
jobs expire after 600s without activity (polling or reporting progress counts
as activity). A 404 therefore means the job never existed, was already
collected, or expired — not necessarily that the id was wrong.
Applies a list of approved keyword merge pairs to the backend's metadata records.
Saves a photo's Lightroom develop settings as a labeled training example.
develop_settings is the plugin's JSON.lua encoding of
photo:getDevelopSettings(). "[]" — how JSON.lua writes an empty table — is
accepted as no settings; any other non-object is a 400. The blob is read with
lrg_develop::lua::read and stored three ways: verbatim (develop_settings),
as the learnable canonical numbers (canonical_settings, the registry's recipe
aliases) and as the typed white balance (white_balance, the
{mode, family, temperature?, tint?} shape above, numbers only for Custom),
together with canonical_version.
is_raw is stored from the white-balance keys when the blob has them
(Temperature → raw, IncrementalTemperature → non-raw, which also covers a
DNG converted from a JPEG); the plugin's flag is only the fallback, and a
contradiction is logged. A blob that carries both families is ambiguous: its
white_balance is not stored whatever the flag says, and the response says the
white balance was not learned. The style engine uses the stored
white_balance.family; is_raw is the hint for re-deriving older rows and the
filter for the /v1/edit/recipe few-shot examples.
The SigLIP2 vector is stored together with the embedding_model that made it,
which decides whether the example can still be matched after the search model
changes.
Response: {status, photo_id, total_count, warnings: [...]}, plus the
legacy joined warning string when there are warnings (no embedding computed,
process version older than PV2012, both white-balance families in one blob).
Lists all stored training examples.
Returns aggregated statistics about stored training examples (count per label, coverage).
Returns the total number of stored training examples.
Removes the training example for a specific photo.
Clears all training examples.
Returns whether the SigLIP2 embedding model is downloaded and ready.
Triggers a background download of the CLIP model.
Returns the current progress of an ongoing CLIP model download.
The per-family routes below still exist and are what the detail view uses, but the plugin's setup flows drive these instead: three downloads, three progress bars and three ready indicators ask a photographer to care about which neural network does which job.
Per-family readiness plus one overall ready flag and missing_approx_bytes
for the "this will download about N GB" line. All six families — clip, aesthetic
(the photo quality scorer), bioclip, face, face_state (eye state, smile and gaze,
~7 MB, tag face-state-assets-v1) and subject (RF-DETR-Seg Nano, which finds a photo's
subject for culling, ~123 MB, tag subject-assets-v1) — are downloadable: true and all
six gate ready. face_state and subject are families of their own so that face
detection never reports "not ready" because of them.
Face detection used to be the exception: its buffalo_l weights came from
INSIGHTFACE_ROOT, were not redistributable, and had to be excluded from
ready so the button did not stay red forever over something the download
could not fix. Replacing them with YuNet + FaceNet removed that carve-out;
downloadable remains in the response because the plugin reads it.
Downloads every downloadable family that is missing, under one progress
entry keyed assets. Families already on disk are skipped, so this doubles as
"finish setting up" after an upgrade, or after a run that could not fetch one
family. With nothing missing it reports completed rather than idling.
It goes family by family. A family whose files cannot be resolved (its up-front HEAD request fails or is not 2xx) or fetched is skipped, and the others still download. Each family has its own release tag, published by its own workflow. One that is not published yet, a failed workflow run or a blocked host used to stop the whole download, the search model included.
-
totalis known before the first byte. It is the summedContent-Lengthof the families whose HEAD requests answered. A family that fails part-way takes the bytes it will not fetch out oftotal, soprogressstill ends attotal. - The job stays
downloadinguntil every family has been tried. It endscompletedonly if every family landed; otherwise it endserror:-
error_messagesholds one"<family name>: <reason>"per family that did not download, with thenamefromGET /v1/models/assets— e.g."Species identification: bioclip2_taxa.bin not found at … (HTTP 404 Not Found) — the release may not exist yet". -
errorlists them all and says what to do next. The plugin shows it. -
GET /v1/models/assetsthen reports the families that landed asready.
-
Same shape as the other download-status routes, plus error_messages (see
above) when a family did not download. The key is absent otherwise.
BioCLIP 2 identifies animals, plants and fungi down to species. It ships as
three assets — the ViT-L/14 image tower as fp16 ONNX, a pruned Tree-of-Life
zero-shot head (bioclip2_taxa.bin) and its interned labels
(bioclip2_taxa.json) — from their own release tag
(bioclip-assets-v1), independent of the SigLIP2 model-assets-v2 tag.
Roughly 876 MB in total.
The head is pruned from upstream's 867,455 taxa; see
server-rs/scripts/bioclip_taxa_filter.toml for the rules and for what
pruning costs at the species rank.
Returns whether the BioCLIP assets are on disk (bioclip: "ready" | "not_ready"),
plus model — the head identifier, e.g. bioclip-2/taxa-v2, read from the
labels file when the head is not loaded, so it answers without pulling 866 MB
into memory. Deliberately distinct from /v1/server/health's species_model, which
reports whether it is currently resident in memory.
Triggers a background download of the BioCLIP assets.
Returns the current progress of an ongoing BioCLIP download. Same shape as the CLIP and LLM download status; all three share one progress map keyed by asset group, so a species download and a GGUF download can be in flight at once.
The pruned Tree-of-Life head carries taxon names only, no identifiers, so a
link from an identification to a species database can either be a name-based
search or a deep link to the taxon's own page. These routes buy the deep link:
they resolve a name against GBIF's /species/match and iNaturalist's
/v1/taxa — both free and unauthenticated — and cache the answer in
~/.cache/lrgenius/species_links.json, so a given taxon costs one outbound
call per machine, ever.
Everything degrades to search URLs: an unreachable network, an unknown name,
or a fuzzy iNaturalist hit that does not match the queried name exactly all
return the same shape with resolved: false. Deliberately kept off the
indexing path — indexing must not need the network, and a catalog's taxa
repeat so heavily that resolving on demand costs a handful of calls for a whole
library. Outbound calls are spaced ~1.1 s apart to stay inside iNaturalist's
published rate limit.
Query: name (scientific name, required), rank (species, genus, … —
narrows both lookups so a genus query cannot match a same-named species),
lang (two-letter; picks the Wikipedia edition and the iNaturalist
common-name locale, default en).
{
"status": "success",
"links": {
"name": "Panthera leo",
"gbif_key": 5219404,
"inat_id": 41964,
"common_name": "Lion",
"resolved": true,
"urls": {
"gbif": "https://www.gbif.org/species/5219404",
"inaturalist": "https://www.inaturalist.org/taxa/41964",
"wikipedia": "https://en.wikipedia.org/wiki/Lion",
"wikispecies": "https://species.wikimedia.org/wiki/Panthera%20leo",
"eol": "https://eol.org/search?q=Panthera+leo",
"col": "https://www.catalogueoflife.org/data/search?q=Panthera+leo"
}
}
}urls always holds every provider; resolved says whether any of them is a
deep link rather than a search. wikispecies deep-links without a lookup —
Wikispecies files scientific names as article titles verbatim. wikipedia
comes from iNaturalist and is therefore only a deep link for lang=en; other
languages get a scientific-name search on their own edition, which resolves
through the redirect each edition keeps.
The plugin calls this from MetadataManager.applySpecies and writes
urls.inaturalist / urls.wikipedia into the read-only speciesInatUrl /
speciesWikipediaUrl catalog fields, which Lightroom renders as clickable
links in the Metadata panel.
Same resolution for several taxa in one request. Resolved sequentially — the rate gate would serialize concurrent calls anyway.
{ "names": [{ "name": "Panthera leo", "rank": "species" }], "lang": "en" }Returns { "status": "success", "links": [ … ] }, one entry per requested
name, in request order.
The backend hosts two local inference engines: llamacpp (in-process llama.cpp, GGUF models, present only in builds compiled with the llamacpp cargo feature) and mlx (an lrgenius-mlx helper process, Apple silicon only, no cargo feature). Both are exposed through the same routes — the MLX half is nested under an mlx key so that older plugins reading the top-level fields still see the llama.cpp engine.
Lists local models that are installed and those offered for download.
{
"installed": [{ "name": "...", "model_path": "...", "mmproj_path": "...", "source": "downloaded|env|lmstudio" }],
"downloadable": [{ "id": "gemma4-e4b", "label": "...", "approx_bytes": 0, "min_ram_gb": 16, "installed": false }],
"supported": true,
"model_dir": "~/.cache/lrgenius/models/llm",
"mlx": {
"installed": [{ "name": "gemma-4-e4b-it-4bit", "model_dir": "...", "source": "downloaded|env|lmstudio|huggingface" }],
"downloadable": [{ "id": "mlx-gemma4-e4b", "label": "...", "approx_bytes": 0, "min_ram_gb": 16, "installed": false }],
"supported": false,
"reason": "MLX runs only on Apple silicon Macs. …",
"model_dir": "~/.cache/lrgenius/models/mlx"
}
}supported: false always carries a reason for MLX; for llama.cpp it means the binary was built without the llamacpp feature.
Engine state (status, loaded model_name, …) for the llama.cpp engine, with the MLX engine's equivalent nested under mlx. n_ctx is the whole KV cache; n_ctx_seq is the share one photo gets, which is what a prompt plus its max_tokens has to fit inside. They differ whenever n_parallel > 1, because llama.cpp splits the cache per sequence. n_parallel is the effective value: the engine lowers a requested count that would leave each photo too little.
Starts a background download of a catalog entry. Body: { "id": "gemma4-e4b" }. The id space is shared across both catalogs and the id alone selects the backend, so there is a single download queue: a GGUF pair is fetched as two files, an MLX entry as a repo snapshot staged into a hidden .<name>.part directory (which discovery skips) and renamed on success.
Or a model from Hugging Face that is not in a catalog: { "repo": "mlx-community/gemma-3-12b-it-qat-4bit", "engine": "mlx", "revision": "<sha from the check>" }. The repo is checked again exactly as by /v1/llm/downloads/check, at that revision, and refused with the same 400/502 if it does not pass — so what is fetched is what was checked. A GGUF goes into a folder of its own under the llama.cpp model directory; an MLX model gets a .lrgenius-source.json naming its repo. When the model is already installed, the download completes at once with its installed_name.
Checks a Hugging Face model before anything is downloaded ("Other model from Hugging Face…" in the plugin). Body: { "repo": "<org/name, org/name:QUANT, or a huggingface.co address>", "engine": "mlx" | "llamacpp" }.
-
400
{ "error": … }— the model cannot be used, with the reason and what to do instead: not a model name, no such public repo, gated, text-only, not an MLX conversion (or not a GGUF), an MLXmodel_typeorprocessor_classthe pinned mlx-swift-lm does not register (hf_repo::MLX_VISION_MODEL_TYPES/MLX_VISION_PROCESSORS), no tokenizer or chat template, no GGUF vision projector, a split GGUF, an unknown quantization. Also when the engine is not available on this machine. -
502
{ "error": … }— Hugging Face could not be reached. - 200 — what a download would fetch:
{
"engine": "mlx", "repo": "mlx-community/gemma-3-12b-it-qat-4bit",
"revision": "<commit sha>", "dir_name": "gemma-3-12b-it-qat-4bit",
"installed_name": "gemma-3-12b-it-qat-4bit",
"files": [{ "path": "config.json", "size": 5000 }, "…"],
"approx_bytes": 8031095500, "est_ram_gb": 11.1,
"model_type": "gemma3", "quant": null,
"already_installed": false,
"warnings": ["This model is not in LrGeniusAI's tested list. …"]
}For llamacpp, files is the model file then its projector (Q4_K_M and an f16 projector unless a quantization follows a colon), quant is set and model_type is null. installed_name is the model to send once it is downloaded. The Hugging Face address can be overridden with HF_ENDPOINT.
Only one local model downloads at a time. While one is running, a second request gets 409 with { "error": "Another model download is still running. …" } — it is refused, not queued.
Progress of the current local-model download (same shape as the CLIP download status). Once status is "completed", installed_name is the name the model is now offered under — the model to send with provider llamacpp or mlx.
Returns aggregate database statistics:
- total indexed photos
- photos with SigLIP embeddings (
with_embedding), and how many of those were made by another search model than this build's (with_outdated_embedding; a search cannot find them until they are indexed again) - photos with title / caption / keywords
- total detected faces
- total persons
Creates and streams a ZIP backup of the full persistent LanceDB data directory,
less two of its folders: backups (the earlier backups) and thumbnails (the
search page's previews — already-compressed JPEG the in-memory zip would only
carry, and which indexing makes again; see GET /v1/photos/thumbnail).
Not available:
POST /db/migrate-photo-idsexisted on the retired Python backend and converted legacy Lightroom UUID-based IDs tophoto_idvalues. It was deliberately not carried over (see the module comment inroutes/db.rs), and the plugin no longer offers it: a database from before file-basedphoto_idvalues has to be re-indexed.The unrelated one-time ChromaDB → LanceDB migration is a CLI subcommand, not an endpoint:
geniusai-server migrate --db-path <path>.
All endpoints return:
{
"results": { ... },
"error": null,
"warning": null
}-
results— the payload (varies per endpoint). -
error— a string if an error occurred,nullotherwise. -
warning— a non-fatal warning string,nullotherwise.
Newer endpoints (indexing, faces, /v1/edit/*) return a warnings array, one
entry per note. Where both exist, warning is a legacy newline-joined copy of
warnings for installed plugins that read only the string.
On HTTP error responses (4xx/5xx), error is always set.