Share clipEmbedding, and shorten the README's keyword sentences
check / check (push) Canceled after 0s

clipKeywords takes the CLIP embedding out of a file's ML data with
clipEmbedding, now exported from src/library/mldata.ts, and adds only its own
checks: 512 numbers, not all zero. bin/clip-keyword-vectors puts its body in
a main function called at the bottom, as the scripts under script/ do; it
writes the same files. The README lists the Discover categories as bullets
and states the score, the rule and the video rule in short sentences of their
own.

Model: opus-5-5
This commit is contained in:
2026-10-22 19:25:40 +00:00
parent 791f17b84e
commit c65b0fae79
4 changed files with 72 additions and 47 deletions
+54 -32
View File
@@ -791,10 +791,11 @@ as it was and goes into `failures.json` (see "Backup layout").
`backup-metadata <dir>` writes `account.json` and, for each collection, a `backup-metadata <dir>` writes `account.json` and, for each collection, a
directory `collections/<id>-<name>/` holding `_collection.json` and a directory `collections/<id>-<name>/` holding `_collection.json` and a
`<fileID>.json` for each of its files, with the file's ML data and keywords as `<fileID>.json` for each of its files. Each `<fileID>.json` holds the file's ML
`quak backup` writes them (see "Backup layout"). The collection's name has data and keywords as `quak backup` writes them (see "Backup layout"). The
unsafe characters replaced and is cut short between two characters so that the collection's name has unsafe characters replaced and is cut short between two
directory's name fits in 255 bytes; the ID keeps the directories apart. characters so that the directory's name fits in 255 bytes; the ID keeps the
directories apart.
`backup-metadata` fetches ML data in requests of up to 200 files. When a request `backup-metadata` fetches ML data in requests of up to 200 files. When a request
fails, the error is logged, each of its files is written with the reason in an fails, the error is logged, each of its files is written with the reason in an
@@ -863,17 +864,38 @@ and its `magicMetadata` and `pubMagicMetadata` when it has them. Update times
are in microseconds, as Ente records them. are in microseconds, as Ente records them.
A file's JSON holds Ente's ML data for it as `mlData`, such as the faces Ente A file's JSON holds Ente's ML data for it as `mlData`, such as the faces Ente
found, without its CLIP embedding. In place of the embedding it holds found, without its CLIP embedding. It holds `keywords` in place of the
`keywords`, the titles of the categories of the Discover section of Ente's embedding.
photos app that the file falls in, highest score first. The categories are
Identity, Screenshots, QR Codes, Receipts, Notes, Memes, Visiting Cards, Babies, The Discover section of Ente's photos app has 16 categories:
Pets, Selfies, Wallpapers, Food, Celebrations, Sunset, Hills and Greenery. As in
Ente's app, a file falls in a category when the cosine similarity of its CLIP - Identity (no videos)
embedding and the text embedding of the category's query is at least the - Screenshots (no videos)
category's minimum score, and a video stays out of Identity, Screenshots, QR - QR Codes (no videos)
Codes, Visiting Cards and Wallpapers. A file with no ML data has neither field, - Receipts
and one whose ML data holds no usable CLIP embedding has no `keywords`. - Notes
`backup-metadata` writes both the same way. - Memes
- Visiting Cards (no videos)
- Babies
- Pets
- Selfies
- Wallpapers (no videos)
- Food
- Celebrations
- Sunset
- Hills
- Greenery
A file's keywords are the titles of the categories it falls in. A file's score
for a category is the cosine similarity of its CLIP embedding and the text
embedding of the category's query. As in Ente's app, the file falls in the
category when that score is at least the category's minimum score. A video never
falls in a category marked "no videos". The keywords are listed highest score
first.
A file with no ML data has neither `mlData` nor `keywords`. A file whose ML data
holds no usable CLIP embedding has no `keywords`. `backup-metadata` writes both
fields the same way.
The backup waits for the library's ML data fetch to finish before it writes the The backup waits for the library's ML data fetch to finish before it writes the
JSON files. If that fetch fails, each file whose ML data is not in the cache JSON files. If that fetch fails, each file whose ML data is not in the cache
@@ -1289,18 +1311,18 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
not already at its save path there as `photo.download()` does (and, with not already at its save path there as `photo.download()` does (and, with
`includeThumbnails`, fetches thumbnails) through the content cache, waits for `includeThumbnails`, fetches thumbnails) through the content cache, waits for
an ML data fetch, and rebuilds the on-disk backup tree with a durable failure an ML data fetch, and rebuilds the on-disk backup tree with a durable failure
ledger. Each file's JSON holds its ML data and keywords and, for an image (for ledger. Each file's JSON holds its ML data and keywords. An image's JSON also
a live photo, its image), its original's EXIF and XMP, with its dimensions for holds the EXIF and XMP of its original, and its dimensions if it is a JPEG. A
a JPEG only; a video's JSON holds none of these three. A fetched original is live photo's JSON holds these for its image. A video's JSON holds none of
written straight to its save path and not into the cache, which then counts it them. A fetched original is written straight to its save path and not into the
as present; one the cache already held is copied from there. `BackupOptions`: cache, which then counts it as present; one the cache already held is copied
`downloadDirectory` (falls back to the library's), `includeOriginals` (default from there. `BackupOptions`: `downloadDirectory` (falls back to the
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` library's), `includeOriginals` (default `true`), `includeThumbnails` (default
(default `false`), `onProgress`, and `lock`. See Backup layout above for the `false`), `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and
tree it writes, and for the albums `BackupResult` lists in `lock`. See Backup layout above for the tree it writes, and for the albums
`undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or `BackupResult` lists in `undecryptableAlbums` and `failedAlbums`. Whether the
its caller did, once the lock is lost the backup rejects with backup took the lock or its caller did, once the lock is lost the backup
`lost the lock on <dir>: <reason>` before it starts another file. rejects with `lost the lock on <dir>: <reason>` before it starts another file.
### Request pools ### Request pools
@@ -1385,11 +1407,11 @@ against the upstream implementations in `web/packages/base/`,
The keywords come from Ente too. `src/clip-keyword-vectors.ts` holds the The keywords come from Ente too. `src/clip-keyword-vectors.ts` holds the
Discover categories of Ente's photos app, from Discover categories of Ente's photos app, from
`mobile/apps/photos/assets/discover.json`, each with the text embedding Ente's `mobile/apps/photos/assets/discover.json`, each with its query's text embedding
CLIP text model gives its query. `bin/clip-keyword-vectors` writes that file, from Ente's CLIP text model. `bin/clip-keyword-vectors` writes that file and the
and the CLIP embedding of a public-domain photo that the tests use, from Ente's CLIP embedding of a public-domain photo that the tests use. It runs Ente's
list and models; it needs docker, pins every download by sha256, and runs only models in Docker, with every download pinned by sha256. It is run by hand only,
by hand, followed by `make fmt`. followed by `make fmt`.
## For LLMs ## For LLMs
+14 -9
View File
@@ -5,21 +5,26 @@
# no gate runs it. It downloads about 215 MB, most of it Ente's two CLIP models. # no gate runs it. It downloads about 215 MB, most of it Ente's two CLIP models.
set -eu set -eu
cd "$(dirname "$0")/.." ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# python:3.13-slim (Python 3.13.17 on Debian 13.7), 2026-10-21. The wheels # python:3.13-slim (Python 3.13.17 on Debian 13.7), 2026-10-21. The wheels
# below are the ones for this image on linux/amd64. # below are the ones for this image on linux/amd64.
image=python@sha256:57b29a7c050c4e679e4bea572e14b3965cf4b5839de2da49d55e49ffc4024f39 IMAGE=python@sha256:57b29a7c050c4e679e4bea572e14b3965cf4b5839de2da49d55e49ffc4024f39
docker run --rm -i --platform linux/amd64 \ main() {
--user "$(id -u):$(id -g)" -e HOME=/tmp \ cd "$ROOT"
-v "$PWD:/repo" -w /repo "$image" sh -c ' docker run --rm -i --platform linux/amd64 \
python -m venv /tmp/venv && --user "$(id -u):$(id -g)" -e HOME=/tmp \
/tmp/venv/bin/pip install --quiet --disable-pip-version-check \ -v "$ROOT:/repo" -w /repo "$IMAGE" sh -c '
--no-cache-dir --no-deps --require-hashes -r /dev/stdin && python -m venv /tmp/venv &&
/tmp/venv/bin/python bin/clip-keyword-vectors.py' <<'EOF' /tmp/venv/bin/pip install --quiet --disable-pip-version-check \
--no-cache-dir --no-deps --require-hashes -r /dev/stdin &&
/tmp/venv/bin/python bin/clip-keyword-vectors.py' <<'EOF'
numpy==2.5.3 --hash=sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321 numpy==2.5.3 --hash=sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321
onnxruntime==1.30.0 --hash=sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f onnxruntime==1.30.0 --hash=sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f
pillow==12.3.0 --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 pillow==12.3.0 --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3
tokenizers==0.23.2 --hash=sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305 tokenizers==0.23.2 --hash=sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305
EOF EOF
}
main "$@"
+3 -5
View File
@@ -10,6 +10,7 @@
// their text embeddings are in `clip-keyword-vectors.ts`. // their text embeddings are in `clip-keyword-vectors.ts`.
import { DISCOVER_CATEGORIES } from "./clip-keyword-vectors.js"; import { DISCOVER_CATEGORIES } from "./clip-keyword-vectors.js";
import { clipEmbedding } from "./library/mldata.js";
import type { MLData } from "./mldata-fetch.js"; import type { MLData } from "./mldata-fetch.js";
// How many numbers a CLIP embedding from Ente's image model has. // How many numbers a CLIP embedding from Ente's image model has.
@@ -33,13 +34,10 @@ export const clipKeywords = (
isVideo: boolean, isVideo: boolean,
categories = DISCOVER_CATEGORIES, categories = DISCOVER_CATEGORIES,
): string[] | undefined => { ): string[] | undefined => {
const clip = mlData.clip; const embedding = clipEmbedding(mlData);
if (typeof clip !== "object" || clip === null) return undefined;
const embedding = (clip as { embedding?: unknown }).embedding;
if ( if (
!Array.isArray(embedding) || !embedding ||
embedding.length !== EMBEDDING_LENGTH || embedding.length !== EMBEDDING_LENGTH ||
!embedding.every((v) => typeof v === "number" && Number.isFinite(v)) ||
length(embedding) === 0 length(embedding) === 0
) { ) {
return undefined; return undefined;
+1 -1
View File
@@ -61,7 +61,7 @@ export interface MLIndex {
// Pull the CLIP embedding out of a payload, or undefined when it is absent or // Pull the CLIP embedding out of a payload, or undefined when it is absent or
// misshapen. Kept strict so a bad payload is skipped rather than corrupting the // misshapen. Kept strict so a bad payload is skipped rather than corrupting the
// packed index. // packed index.
const clipEmbedding = (payload: MLData): number[] | undefined => { export const clipEmbedding = (payload: MLData): number[] | undefined => {
const clip = payload.clip; const clip = payload.clip;
if (typeof clip !== "object" || clip === null) return undefined; if (typeof clip !== "object" || clip === null) return undefined;
const embedding = (clip as { embedding?: unknown }).embedding; const embedding = (clip as { embedding?: unknown }).embedding;