Release v0.2.2
This commit is contained in:
1 parent
76480f07a0
commit
48b7cb17df
31 files changed
+4327
-809
No files matched your search
@@ -223,7 +223,58 @@ jobs:
|
||||
set -eu
|
||||
work=/tmp/pixhelf-oci
|
||||
local_registry=127.0.0.1:5000
|
||||
mkdir -p "$work"
|
||||
model_revision=f4a64596bbcf9a2a94591b74b9dc39b2e4e77e3e
|
||||
model_name=chinese-clip-vit-base-patch16-f4a64596
|
||||
model_path="/opt/pixhelf/models/$model_name"
|
||||
model_root="$work/model-root"
|
||||
model_layer="$work/model-layer.tar"
|
||||
model_base_ref="$local_registry/staging:model-base"
|
||||
mkdir -p "$model_root$model_path"
|
||||
|
||||
# Keep this metadata in sync with src/text_search/download.rs and Dockerfile.
|
||||
download_model_artifact() {
|
||||
filename="$1"
|
||||
checksum="$2"
|
||||
expected_size="$3"
|
||||
destination="$model_root$model_path/$filename"
|
||||
url="https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16/resolve/$model_revision/$filename"
|
||||
|
||||
curl --proto '=https' --tlsv1.2 --silent --show-error --fail --location \
|
||||
--retry 5 \
|
||||
--retry-all-errors \
|
||||
--continue-at - \
|
||||
"$url" \
|
||||
--output "$destination"
|
||||
printf '%s %s\n' "$checksum" "$destination" | sha256sum --check -
|
||||
actual_size="$(wc -c < "$destination" | tr -d ' ')"
|
||||
test "$actual_size" = "$expected_size"
|
||||
chmod 0644 "$destination"
|
||||
}
|
||||
|
||||
download_model_artifact \
|
||||
model.safetensors \
|
||||
29cc0b2bcf6ff777f2e15742be92b110e4acbdb2068356e862c4637a4b15fe4f \
|
||||
753106020
|
||||
download_model_artifact \
|
||||
vocab.txt \
|
||||
45bbac6b341c319adc98a532532882e91a9cefc0329aa57bac9ae761c27b291c \
|
||||
109540
|
||||
|
||||
chmod 0755 \
|
||||
"$model_root/opt" \
|
||||
"$model_root/opt/pixhelf" \
|
||||
"$model_root/opt/pixhelf/models" \
|
||||
"$model_root$model_path"
|
||||
|
||||
tar \
|
||||
--sort=name \
|
||||
--mtime=@0 \
|
||||
--owner=0 \
|
||||
--group=0 \
|
||||
--numeric-owner \
|
||||
-cf "$model_layer" \
|
||||
-C "$model_root" \
|
||||
opt
|
||||
|
||||
crane registry serve --address "$local_registry" >"$work/registry.log" 2>&1 &
|
||||
registry_pid=$!
|
||||
@@ -234,6 +285,21 @@ jobs:
|
||||
trap cleanup EXIT
|
||||
sleep 1
|
||||
|
||||
crane append --insecure --oci-empty-base \
|
||||
--new_layer "$model_layer" \
|
||||
--new_tag "$model_base_ref"
|
||||
model_layer_digest="$(crane manifest --insecure "$model_base_ref" | node -e '
|
||||
let input = "";
|
||||
process.stdin.on("data", (chunk) => input += chunk);
|
||||
process.stdin.on("end", () => {
|
||||
const manifest = JSON.parse(input);
|
||||
if (manifest.layers?.length !== 1) {
|
||||
throw new Error(`model base has ${manifest.layers?.length ?? 0} layers`);
|
||||
}
|
||||
process.stdout.write(manifest.layers[0].digest);
|
||||
});
|
||||
')"
|
||||
|
||||
build_platform() {
|
||||
arch="$1"
|
||||
asset="$2"
|
||||
@@ -254,7 +320,8 @@ jobs:
|
||||
-C "$rootfs" \
|
||||
pixhelf
|
||||
|
||||
crane append --insecure --oci-empty-base \
|
||||
crane append --insecure \
|
||||
--base "$model_base_ref" \
|
||||
--new_layer "$platform_dir/layer.tar" \
|
||||
--new_tag "$base_ref"
|
||||
crane mutate --insecure "$base_ref" \
|
||||
@@ -263,6 +330,7 @@ jobs:
|
||||
--entrypoint /pixhelf \
|
||||
--workdir /data \
|
||||
--exposed-ports 3002/tcp \
|
||||
--env "PIXHELF_TEXT_SEARCH_MODEL=$model_path" \
|
||||
--label org.opencontainers.image.title=Pixhelf \
|
||||
--label 'org.opencontainers.image.description=Self-hosted image gallery' \
|
||||
--label "org.opencontainers.image.version=$IMAGE_VERSION" \
|
||||
@@ -270,7 +338,7 @@ jobs:
|
||||
--label "org.opencontainers.image.source=$SOURCE_URL"
|
||||
|
||||
crane config --insecure "$configured_ref" \
|
||||
| ARCH="$arch" node -e '
|
||||
| ARCH="$arch" MODEL_PATH="$model_path" node -e '
|
||||
let input = "";
|
||||
process.stdin.on("data", (chunk) => input += chunk);
|
||||
process.stdin.on("end", () => {
|
||||
@@ -287,10 +355,31 @@ jobs:
|
||||
if (!config.config.ExposedPorts?.["3002/tcp"]) {
|
||||
throw new Error("missing exposed port");
|
||||
}
|
||||
const expectedModel = `PIXHELF_TEXT_SEARCH_MODEL=${process.env.MODEL_PATH}`;
|
||||
if (!config.config.Env?.includes(expectedModel)) {
|
||||
throw new Error("missing bundled model configuration");
|
||||
}
|
||||
});'
|
||||
|
||||
app_layer_digest="$(crane manifest --insecure "$configured_ref" \
|
||||
| EXPECTED_MODEL_LAYER="$model_layer_digest" node -e '
|
||||
let input = "";
|
||||
process.stdin.on("data", (chunk) => input += chunk);
|
||||
process.stdin.on("end", () => {
|
||||
const manifest = JSON.parse(input);
|
||||
if (manifest.layers?.length !== 2) {
|
||||
throw new Error(`application image has ${manifest.layers?.length ?? 0} layers`);
|
||||
}
|
||||
if (manifest.layers[0].digest !== process.env.EXPECTED_MODEL_LAYER) {
|
||||
throw new Error("application image does not reuse the model base layer");
|
||||
}
|
||||
process.stdout.write(manifest.layers[1].digest);
|
||||
});'
|
||||
)"
|
||||
|
||||
expected="$(sha256sum "$asset" | cut -d ' ' -f 1)"
|
||||
actual="$(crane export --insecure "$configured_ref" - \
|
||||
actual="$(crane blob --insecure "$local_registry/staging@$app_layer_digest" \
|
||||
| gzip -dc \
|
||||
| tar -xOf - pixhelf \
|
||||
| sha256sum \
|
||||
| cut -d ' ' -f 1)"
|
||||
|
||||
Generated
+787
-19
File diff suppressed because it is too large.
Load diff
+5
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pixhelf"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
@@ -12,10 +12,13 @@ candle-core = { version = "0.9.2", default-features = false }
|
||||
candle-nn = { version = "0.9.2", default-features = false }
|
||||
candle-transformers = { version = "0.9.2", default-features = false }
|
||||
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
kamadak-exif = "0.6.1"
|
||||
natord = "1.0.9"
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls-webpki-roots"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
tokio = { version = "1.53.1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
sha2 = "0.10.9"
|
||||
tokio = { version = "1.53.1", features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.6.11", features = ["compression-br", "compression-gzip", "fs", "set-header", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
|
||||
+15
-1
@@ -1,6 +1,18 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM scratch
|
||||
FROM scratch AS model-base
|
||||
|
||||
# Keep these pinned artifacts in sync with src/text_search/download.rs and the
|
||||
# release workflow. Registry compression and content-addressing make these
|
||||
# layers reusable until the model revision changes.
|
||||
ADD --checksum=sha256:29cc0b2bcf6ff777f2e15742be92b110e4acbdb2068356e862c4637a4b15fe4f \
|
||||
https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16/resolve/f4a64596bbcf9a2a94591b74b9dc39b2e4e77e3e/model.safetensors \
|
||||
/opt/pixhelf/models/chinese-clip-vit-base-patch16-f4a64596/model.safetensors
|
||||
ADD --checksum=sha256:45bbac6b341c319adc98a532532882e91a9cefc0329aa57bac9ae761c27b291c \
|
||||
https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16/resolve/f4a64596bbcf9a2a94591b74b9dc39b2e4e77e3e/vocab.txt \
|
||||
/opt/pixhelf/models/chinese-clip-vit-base-patch16-f4a64596/vocab.txt
|
||||
|
||||
FROM model-base
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG VERSION
|
||||
@@ -13,6 +25,8 @@ LABEL org.opencontainers.image.title="Pixhelf" \
|
||||
org.opencontainers.image.revision="${REVISION}" \
|
||||
org.opencontainers.image.source="${SOURCE_URL}"
|
||||
|
||||
ENV PIXHELF_TEXT_SEARCH_MODEL=/opt/pixhelf/models/chinese-clip-vit-base-patch16-f4a64596
|
||||
|
||||
COPY --chmod=0755 dist/pixhelf-${TARGETARCH}-linux /pixhelf
|
||||
|
||||
WORKDIR /data
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Pixhelf
|
||||
|
||||
轻量的本地照片画廊,支持 JPEG、PNG、WebP、自然语言文字搜图、随机探索和本地以图搜图。相似图片会直接显示在图片查看器下方的分页瀑布流中。
|
||||
轻量的本地照片画廊,支持 JPEG、PNG、WebP、照片直方图与 EXIF、自然语言文字搜图、随机探索和本地以图搜图。相似图片会直接显示在图片查看器下方的分页瀑布流中。
|
||||
|
||||
## Docker Compose
|
||||
|
||||
@@ -51,8 +51,8 @@ services:
|
||||
| --- | --- | --- |
|
||||
| `PIXHELF_GALLERY_DIR` | `--gallery-dir` | `./pic` |
|
||||
| `PIXHELF_CACHE_DIR` | `--cache-dir` | `./.pixhelf-cache/thumbnails` |
|
||||
| `PIXHELF_TEXT_SEARCH_MODEL` | `--text-search-model` | 不启用 |
|
||||
| `PIXHELF_TEXT_SEARCH_VOCAB` | `--text-search-vocab` | 模型同目录下的 `vocab.txt` |
|
||||
| `PIXHELF_TEXT_SEARCH_MODEL` | `--text-search-model` | Docker 镜像使用内置模型;独立二进制为 `true` 并自动下载;`false` 关闭,也可填写模型目录或文件 |
|
||||
| `PIXHELF_TEXT_SEARCH_VOCAB` | `--text-search-vocab` | 可选覆盖;默认读取模型目录的 `vocab.txt` |
|
||||
| `PIXHELF_LISTEN` | `--listen` | `0.0.0.0:3002` |
|
||||
| `PIXHELF_INITIAL_BATCH` | `--initial-batch` | `60` |
|
||||
| `PIXHELF_WORKERS` | `--workers` | 根据 CPU 自动选择 `2`–`4` |
|
||||
@@ -60,49 +60,35 @@ services:
|
||||
|
||||
## 自然语言文字搜图
|
||||
|
||||
可选的 Chinese-CLIP ViT-B/16 模型支持直接输入中文描述,例如“海边日落”“草地上的狗”或“夜晚城市街道”。图片与文字都在本机编码,Pixhelf 不会在运行时联网或自动下载模型。索引尚未完成时搜索框仍按文件名搜索;完成后自动切换到语义搜索,并保留文件名精确匹配优先级。
|
||||
可选的 Chinese-CLIP ViT-B/16 模型支持直接输入中文描述,例如“海边日落”“草地上的狗”或“夜晚城市街道”。图片与文字编码都在本机完成。索引尚未完成时搜索框仍按文件名搜索;完成后自动切换到语义搜索,并保留文件名精确匹配优先级。
|
||||
|
||||
下载并校验固定版本的官方模型与词表(模型约 753 MB):
|
||||
|
||||
```bash
|
||||
mkdir -p models/chinese-clip
|
||||
curl --proto '=https' --tlsv1.2 --fail --location \
|
||||
https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16/resolve/f4a64596bbcf9a2a94591b74b9dc39b2e4e77e3e/model.safetensors \
|
||||
--output models/chinese-clip/model.safetensors
|
||||
curl --proto '=https' --tlsv1.2 --fail --location \
|
||||
https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16/resolve/f4a64596bbcf9a2a94591b74b9dc39b2e4e77e3e/vocab.txt \
|
||||
--output models/chinese-clip/vocab.txt
|
||||
printf '%s %s\n%s %s\n' \
|
||||
29cc0b2bcf6ff777f2e15742be92b110e4acbdb2068356e862c4637a4b15fe4f \
|
||||
models/chinese-clip/model.safetensors \
|
||||
45bbac6b341c319adc98a532532882e91a9cefc0329aa57bac9ae761c27b291c \
|
||||
models/chinese-clip/vocab.txt | sha256sum --check -
|
||||
```
|
||||
|
||||
Compose 服务增加模型目录挂载与环境变量:
|
||||
语义搜索默认启用,Compose 无需增加模型参数。需要关闭时设置:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
pixhelf:
|
||||
volumes:
|
||||
- "./pic:/data/pic:ro"
|
||||
- pixhelf-cache:/data/.pixhelf-cache
|
||||
- "./models/chinese-clip:/models/chinese-clip:ro"
|
||||
environment:
|
||||
PIXHELF_TEXT_SEARCH_MODEL: /models/chinese-clip/model.safetensors
|
||||
PIXHELF_TEXT_SEARCH_VOCAB: /models/chinese-clip/vocab.txt
|
||||
PIXHELF_TEXT_SEARCH_MODEL: "false"
|
||||
```
|
||||
|
||||
本地运行:
|
||||
命令行同样可以关闭:
|
||||
|
||||
```bash
|
||||
pixhelf \
|
||||
--text-search-model ./models/chinese-clip/model.safetensors \
|
||||
--text-search-vocab ./models/chinese-clip/vocab.txt
|
||||
pixhelf --text-search-model false
|
||||
```
|
||||
|
||||
文字搜图使用一个 CPU 后台任务建立 512 维图像索引,向量按模型与词表指纹保存在缩略图缓存中,每张约 552 字节。侧边栏会显示“文字索引”进度;更换模型文件前请先停止 Pixhelf。
|
||||
官方 Docker 镜像已经包含校验过的固定版本模型与词表,不会在容器启动后访问 Hugging Face。模型位于独立、可复现的压缩 OCI 基础层中:第一次 `docker pull` 会下载该层;以后更新 Pixhelf 时,只要模型版本不变,Docker 会按内容摘要复用本地层,只拉取新的应用层。amd64 与 arm64 镜像也共享同一份模型层。缩略图与搜索索引仍保存在 `pixhelf-cache` 卷中。
|
||||
|
||||
直接运行独立二进制时仍会在首次启动后从 Hugging Face 的 OFA-Sys 官方仓库后台下载并校验模型(约 753 MB)。Web 服务无需等待模型,先以文件名搜索正常启动;下载支持断点续传和后台重试,后续启动复用缓存,无需重复下载。
|
||||
|
||||
离线环境仍可手动提供包含 `model.safetensors` 和 `vocab.txt` 的目录。原来的模型文件路径也继续兼容;仅当词表不在模型同目录时,才需要额外配置 `PIXHELF_TEXT_SEARCH_VOCAB` 或 `--text-search-vocab`。
|
||||
|
||||
文字搜图使用一个 CPU 后台任务建立 512 维图像索引,向量按模型与词表指纹保存在缩略图缓存中,每张约 552 字节。模型权重按需载入内存:已有完整向量缓存时启动不会载入,只有补建索引或执行新的文字查询时才载入;连续空闲 60 秒后自动释放,下次需要时再载入。侧边栏会显示“文字索引”进度;更换模型文件前请先停止 Pixhelf。
|
||||
|
||||
## 本地以图搜图
|
||||
|
||||
以图搜图不需要模型:相似度完全在本机计算,综合比较图片的构图、亮度、色彩分布、边缘纹理和感知哈希,并过滤明显无关的结果。特征描述符会保存在缩略图缓存卷中;升级已有图库时会在后台自动补齐,之后重启无需重新计算。
|
||||
|
||||
## 照片直方图与 EXIF
|
||||
|
||||
在图片查看器中向下滚动即可查看 RGB/亮度直方图、文件大小与修改时间,以及拍摄时间、相机、镜头、快门、光圈、ISO、焦距等常用 EXIF 信息。数据仅在打开查看器时按需读取并缓存在内存中;直方图复用已有缩略图,不会在图库扫描阶段批量解码原图。
|
||||
@@ -6,14 +6,11 @@ services:
|
||||
PIXHELF_INITIAL_BATCH: "60"
|
||||
PIXHELF_WORKERS: "4"
|
||||
PIXHELF_SCAN_INTERVAL: "10"
|
||||
PIXHELF_TEXT_SEARCH_MODEL: "/models/chinese-clip/model.safetensors"
|
||||
PIXHELF_TEXT_SEARCH_VOCAB: "/models/chinese-clip/vocab.txt"
|
||||
ports:
|
||||
- "3002:3002"
|
||||
volumes:
|
||||
- "./pic:/data/pic:ro"
|
||||
- pixhelf-cache:/data/.pixhelf-cache
|
||||
- "./models/chinese-clip:/models/chinese-clip:ro"
|
||||
|
||||
volumes:
|
||||
pixhelf-cache:
|
||||
+4
-1
@@ -7,7 +7,10 @@
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<meta name="theme-color" content="#f4f5f4" />
|
||||
<meta name="description" content="Pixhelf 私人图片画廊" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Pixhelf - A simple gallery application"
|
||||
/>
|
||||
<!--PIXHELF_IMAGE_PRELOAD-->
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23287a72'/%3E%3Cpath d='M8 8h7v7H8zm9 0h7v7h-7zM8 17h7v7H8zm9 0h7v7h-7z' fill='white'/%3E%3C/svg%3E" />
|
||||
<title>Pixhelf</title>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pixhelf-frontend",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"preact": "^10.27.1"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pixhelf-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -315,6 +315,13 @@ try {
|
||||
const viewerChrome = await page.evaluate(() => ({
|
||||
fullscreenButtons: document.querySelectorAll(".viewer-fullscreen").length,
|
||||
detailsPages: document.querySelectorAll(".viewer-details-page").length,
|
||||
detailsToolbars: document.querySelectorAll(".viewer-details-toolbar").length,
|
||||
detailsToolbarControls: document.querySelectorAll(
|
||||
".viewer-details-toolbar > .viewer-control",
|
||||
).length,
|
||||
detailsToolbarUnified: document.querySelector(".viewer-details-toolbar")
|
||||
?.classList.contains("viewer-header-actions") ?? false,
|
||||
detailsInlineActions: document.querySelectorAll(".viewer-details-quick-actions").length,
|
||||
scrollCues: document.querySelectorAll(".viewer-scroll-cue").length,
|
||||
zoomControls: document.querySelectorAll(
|
||||
".viewer-zoom-controls, .viewer-zoom-value",
|
||||
@@ -333,11 +340,21 @@ try {
|
||||
const cueBounds = cue.getBoundingClientRect();
|
||||
return {
|
||||
surfaced: style.backgroundColor !== "rgba(0, 0, 0, 0)"
|
||||
&& Number.parseFloat(style.borderTopWidth) > 0,
|
||||
|| style.backgroundImage !== "none",
|
||||
borderless: Number.parseFloat(style.borderTopWidth) === 0,
|
||||
glass: style.backdropFilter !== "none" || style.webkitBackdropFilter !== "none",
|
||||
noInsetRing: !style.boxShadow.includes("inset"),
|
||||
circular: Number.parseFloat(style.borderTopLeftRadius) >= cueBounds.height / 2 - 1,
|
||||
width: cueBounds.width,
|
||||
};
|
||||
})(),
|
||||
navigationGlass: [...document.querySelectorAll(".viewer-nav")].every((control) => {
|
||||
const style = getComputedStyle(control);
|
||||
return Number.parseFloat(style.borderTopWidth) === 0
|
||||
&& !style.boxShadow.includes("inset")
|
||||
&& (style.backgroundImage !== "none" || style.backgroundColor !== "rgba(0, 0, 0, 0)")
|
||||
&& (style.backdropFilter !== "none" || style.webkitBackdropFilter !== "none");
|
||||
}),
|
||||
floatingToolbar: (() => {
|
||||
const viewer = document.querySelector(".image-viewer");
|
||||
const header = document.querySelector(".viewer-header");
|
||||
@@ -1607,6 +1624,10 @@ try {
|
||||
viewer.previewRequests !== 0 ||
|
||||
viewer.fullscreenButtons !== 1 ||
|
||||
viewer.detailsPages !== 1 ||
|
||||
viewer.detailsToolbars !== 1 ||
|
||||
viewer.detailsToolbarControls !== 5 ||
|
||||
!viewer.detailsToolbarUnified ||
|
||||
viewer.detailsInlineActions !== 0 ||
|
||||
viewer.scrollCues !== 1 ||
|
||||
viewer.headingModules !== 0 ||
|
||||
!viewer.unifiedControls ||
|
||||
@@ -1642,9 +1663,13 @@ try {
|
||||
viewer.switchPerformance.dispatchDuration > 80 ||
|
||||
viewer.switchPerformance.firstFrameDuration > 100 ||
|
||||
!viewerChrome.scrollCuePresentation?.surfaced ||
|
||||
!viewerChrome.scrollCuePresentation?.borderless ||
|
||||
!viewerChrome.scrollCuePresentation?.glass ||
|
||||
!viewerChrome.scrollCuePresentation?.noInsetRing ||
|
||||
!viewerChrome.scrollCuePresentation?.circular ||
|
||||
viewerChrome.scrollCuePresentation.width < 44 ||
|
||||
viewerChrome.scrollCuePresentation.width > 52 ||
|
||||
!viewerChrome.navigationGlass ||
|
||||
viewerChrome.floatingToolbar?.mode !== "floating" ||
|
||||
!viewerChrome.floatingToolbar?.headerTransparent ||
|
||||
!viewerChrome.floatingToolbar?.actionsSurfaced ||
|
||||
|
||||
+146
-18
@@ -48,6 +48,8 @@ const VIEWER_RETURN_SETTLE_MS = 32;
|
||||
const VIEWER_RETURN_TIMEOUT_MS = 480;
|
||||
const VIEWER_RETURN_ANIMATION_MS = 260;
|
||||
const READY_THUMBNAIL_CACHE_LIMIT = 2048;
|
||||
const VIEWER_HISTORY_STATE_KEY = "__pixhelfViewer";
|
||||
const VIEWER_HISTORY_STATE_VERSION = 1;
|
||||
const INITIAL_BOOTSTRAP = takeInitialBootstrap();
|
||||
|
||||
type ImagePageState = {
|
||||
@@ -63,6 +65,11 @@ type SimilarImagePageState = {
|
||||
nextOffset: number | null;
|
||||
};
|
||||
|
||||
type ViewerHistoryEntry = {
|
||||
version: number;
|
||||
image: GalleryImage;
|
||||
};
|
||||
|
||||
type ViewerAnchor = {
|
||||
cardRatio: number;
|
||||
viewportRatio: number;
|
||||
@@ -98,6 +105,42 @@ const EMPTY_SIMILAR_IMAGE_PAGE: SimilarImagePageState = {
|
||||
nextOffset: null,
|
||||
};
|
||||
|
||||
function readViewerHistoryEntry(state: unknown): ViewerHistoryEntry | null {
|
||||
if (!state || typeof state !== "object" || Array.isArray(state)) return null;
|
||||
const value = (state as Record<string, unknown>)[VIEWER_HISTORY_STATE_KEY];
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const entry = value as Record<string, unknown>;
|
||||
const image = entry.image;
|
||||
if (
|
||||
entry.version !== VIEWER_HISTORY_STATE_VERSION
|
||||
|| !image
|
||||
|| typeof image !== "object"
|
||||
|| Array.isArray(image)
|
||||
) return null;
|
||||
const candidate = image as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.id !== "string"
|
||||
|| typeof candidate.name !== "string"
|
||||
|| typeof candidate.width !== "number"
|
||||
|| typeof candidate.height !== "number"
|
||||
) return null;
|
||||
return value as ViewerHistoryEntry;
|
||||
}
|
||||
|
||||
function viewerHistoryState(image: GalleryImage): Record<string, unknown> {
|
||||
const current = window.history.state;
|
||||
const preserved = current && typeof current === "object" && !Array.isArray(current)
|
||||
? current as Record<string, unknown>
|
||||
: {};
|
||||
return {
|
||||
...preserved,
|
||||
[VIEWER_HISTORY_STATE_KEY]: {
|
||||
version: VIEWER_HISTORY_STATE_VERSION,
|
||||
image,
|
||||
} satisfies ViewerHistoryEntry,
|
||||
};
|
||||
}
|
||||
|
||||
function clampValue(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
@@ -565,6 +608,8 @@ function App() {
|
||||
const viewerAnchorRef = useRef<ViewerAnchor | null>(null);
|
||||
const viewerReturnSequenceRef = useRef(0);
|
||||
const viewerReturnFlightRef = useRef<ViewerReturnFlight | null>(null);
|
||||
const viewerHistoryActiveRef = useRef(false);
|
||||
const viewerHistoryClosingRef = useRef(false);
|
||||
const { images, total, nextOffset } = imagePage;
|
||||
const viewerImages = useMemo(
|
||||
() => appendUniqueImages(images, viewerDiscoveredImages),
|
||||
@@ -943,23 +988,7 @@ function App() {
|
||||
),
|
||||
);
|
||||
|
||||
const openViewer = useCallback((
|
||||
imageId: string,
|
||||
card: HTMLElement,
|
||||
pointerY?: number,
|
||||
) => {
|
||||
disposeViewerReturnFlight(viewerReturnFlightRef.current);
|
||||
viewerReturnFlightRef.current = null;
|
||||
viewerAnchorRef.current = captureViewerAnchor(card, pointerY);
|
||||
prepareViewerImages(images, images.findIndex((image) => image.id === imageId));
|
||||
resetSimilarSearch();
|
||||
setViewerDiscoveredImages([]);
|
||||
setViewerReturnRequest(null);
|
||||
viewerImageIdRef.current = imageId;
|
||||
setViewerImageId(imageId);
|
||||
}, [images, resetSimilarSearch]);
|
||||
|
||||
const closeViewer = useCallback(() => {
|
||||
const closeViewerState = useCallback(() => {
|
||||
const currentImageId = viewerImageIdRef.current;
|
||||
if (!currentImageId) return;
|
||||
disposeViewerReturnFlight(viewerReturnFlightRef.current);
|
||||
@@ -981,6 +1010,105 @@ function App() {
|
||||
setViewerImageId(null);
|
||||
}, [resetSimilarSearch]);
|
||||
|
||||
const restoreViewerFromHistory = useCallback((snapshot: GalleryImage) => {
|
||||
disposeViewerReturnFlight(viewerReturnFlightRef.current);
|
||||
viewerReturnFlightRef.current = null;
|
||||
const card = imageCardById(snapshot.id);
|
||||
viewerAnchorRef.current = card ? captureViewerAnchor(card) : null;
|
||||
prepareViewerImages([snapshot], 0);
|
||||
resetSimilarSearch();
|
||||
setViewerDiscoveredImages((current) => appendUniqueImages(current, [snapshot]));
|
||||
setViewerReturnRequest(null);
|
||||
viewerImageIdRef.current = snapshot.id;
|
||||
setViewerImageId(snapshot.id);
|
||||
}, [resetSimilarSearch]);
|
||||
|
||||
const enterViewerHistory = useCallback((snapshot: GalleryImage) => {
|
||||
try {
|
||||
const state = viewerHistoryState(snapshot);
|
||||
if (readViewerHistoryEntry(window.history.state)) {
|
||||
window.history.replaceState(state, "", window.location.href);
|
||||
} else {
|
||||
window.history.pushState(state, "", window.location.href);
|
||||
}
|
||||
viewerHistoryActiveRef.current = true;
|
||||
viewerHistoryClosingRef.current = false;
|
||||
} catch {
|
||||
viewerHistoryActiveRef.current = false;
|
||||
viewerHistoryClosingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openViewer = useCallback((
|
||||
imageId: string,
|
||||
card: HTMLElement,
|
||||
pointerY?: number,
|
||||
) => {
|
||||
const snapshot = images.find((image) => image.id === imageId);
|
||||
if (!viewerImageIdRef.current && snapshot) enterViewerHistory(snapshot);
|
||||
disposeViewerReturnFlight(viewerReturnFlightRef.current);
|
||||
viewerReturnFlightRef.current = null;
|
||||
viewerAnchorRef.current = captureViewerAnchor(card, pointerY);
|
||||
prepareViewerImages(images, images.findIndex((image) => image.id === imageId));
|
||||
resetSimilarSearch();
|
||||
setViewerDiscoveredImages([]);
|
||||
setViewerReturnRequest(null);
|
||||
viewerImageIdRef.current = imageId;
|
||||
setViewerImageId(imageId);
|
||||
}, [enterViewerHistory, images, resetSimilarSearch]);
|
||||
|
||||
const closeViewer = useCallback(() => {
|
||||
if (!viewerImageIdRef.current || viewerHistoryClosingRef.current) return;
|
||||
if (
|
||||
viewerHistoryActiveRef.current
|
||||
&& readViewerHistoryEntry(window.history.state)
|
||||
) {
|
||||
viewerHistoryClosingRef.current = true;
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
viewerHistoryActiveRef.current = false;
|
||||
closeViewerState();
|
||||
}, [closeViewerState]);
|
||||
|
||||
useEffect(() => {
|
||||
const applyHistoryState = (state: unknown) => {
|
||||
const entry = readViewerHistoryEntry(state);
|
||||
viewerHistoryActiveRef.current = Boolean(entry);
|
||||
viewerHistoryClosingRef.current = false;
|
||||
if (entry) {
|
||||
if (viewerImageIdRef.current !== entry.image.id) {
|
||||
restoreViewerFromHistory(entry.image);
|
||||
}
|
||||
} else if (viewerImageIdRef.current) {
|
||||
closeViewerState();
|
||||
}
|
||||
};
|
||||
const onPopState = (event: PopStateEvent) => applyHistoryState(event.state);
|
||||
window.addEventListener("popstate", onPopState);
|
||||
const currentEntry = readViewerHistoryEntry(window.history.state);
|
||||
viewerHistoryActiveRef.current = Boolean(currentEntry);
|
||||
if (currentEntry && !viewerImageIdRef.current) {
|
||||
restoreViewerFromHistory(currentEntry.image);
|
||||
}
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, [closeViewerState, restoreViewerFromHistory]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!viewerImage || !viewerHistoryActiveRef.current) return;
|
||||
const entry = readViewerHistoryEntry(window.history.state);
|
||||
if (!entry || entry.image.id === viewerImage.id) return;
|
||||
try {
|
||||
window.history.replaceState(
|
||||
viewerHistoryState(viewerImage),
|
||||
"",
|
||||
window.location.href,
|
||||
);
|
||||
} catch {
|
||||
viewerHistoryActiveRef.current = false;
|
||||
}
|
||||
}, [viewerImage]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (viewerImageId || !viewerReturnRequest) return;
|
||||
const request = viewerReturnRequest;
|
||||
@@ -1381,7 +1509,7 @@ function App() {
|
||||
similarLoadingMore={similarActive && similarLoadingMore}
|
||||
similarError={similarActive ? similarError : null}
|
||||
onSearchSimilar={searchSimilar}
|
||||
onLoadMoreSimilar={() => void loadMoreSimilar()}
|
||||
onLoadMoreSimilar={loadMoreSimilar}
|
||||
onOpenSimilar={openSimilarImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -113,6 +113,7 @@ export function Header({
|
||||
aria-label={exploreActive ? "换一组图片" : "随机探索"}
|
||||
aria-pressed={exploreActive}
|
||||
aria-busy={exploreLoading}
|
||||
data-state={exploreActive ? "active" : "idle"}
|
||||
title={exploreActive ? "换一组" : "随机探索"}
|
||||
>
|
||||
<Dices
|
||||
@@ -198,6 +199,7 @@ export function SidebarToggleButton({
|
||||
aria-label={label}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={controls}
|
||||
data-state={expanded ? "expanded" : "collapsed"}
|
||||
title={label}
|
||||
>
|
||||
<span key={String(expanded)} className="sidebar-toggle-glyph" aria-hidden="true">
|
||||
|
||||
+216
-202
@@ -14,6 +14,7 @@ import {
|
||||
ScanSearch,
|
||||
X,
|
||||
} from "./icons";
|
||||
import { PhotoInformation } from "./PhotoInformation";
|
||||
import { SimilarImageMasonry, SimilarImageSkeleton } from "./SimilarImages";
|
||||
import type { GalleryImage } from "./types";
|
||||
import {
|
||||
@@ -80,6 +81,15 @@ type GestureMode = "pan" | "navigate" | "page" | "dismiss" | "pinch";
|
||||
|
||||
type ViewerPage = "image" | "transition" | "details";
|
||||
|
||||
type ViewerScrollSnapshot = {
|
||||
imageId: string;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
detailsTop: number;
|
||||
scrollTop: number;
|
||||
page: ViewerPage;
|
||||
};
|
||||
|
||||
type PinchStart = {
|
||||
distance: number;
|
||||
midpoint: PointerPoint;
|
||||
@@ -124,6 +134,26 @@ type IdleScheduler = Window & {
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
type ImageViewerProps = {
|
||||
images: GalleryImage[];
|
||||
activeIndex: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onNavigate: (direction: -1 | 1) => void;
|
||||
onClose: () => void;
|
||||
similarActive: boolean;
|
||||
similarImages: GalleryImage[];
|
||||
similarTotal: number;
|
||||
similarHasMore: boolean;
|
||||
similarLoading: boolean;
|
||||
similarLoadingMore: boolean;
|
||||
similarError: string | null;
|
||||
onSearchSimilar: (image: GalleryImage) => void;
|
||||
onLoadMoreSimilar: () => void;
|
||||
onOpenSimilar: (image: GalleryImage) => void;
|
||||
};
|
||||
|
||||
export function ImageViewer({
|
||||
images,
|
||||
activeIndex,
|
||||
@@ -142,28 +172,17 @@ export function ImageViewer({
|
||||
onSearchSimilar,
|
||||
onLoadMoreSimilar,
|
||||
onOpenSimilar,
|
||||
}: {
|
||||
images: GalleryImage[];
|
||||
activeIndex: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onNavigate: (direction: -1 | 1) => void;
|
||||
onClose: () => void;
|
||||
similarActive: boolean;
|
||||
similarImages: GalleryImage[];
|
||||
similarTotal: number;
|
||||
similarHasMore: boolean;
|
||||
similarLoading: boolean;
|
||||
similarLoadingMore: boolean;
|
||||
similarError: string | null;
|
||||
onSearchSimilar: (image: GalleryImage) => void;
|
||||
onLoadMoreSimilar: () => void;
|
||||
onOpenSimilar: (image: GalleryImage) => void;
|
||||
}) {
|
||||
}: ImageViewerProps) {
|
||||
const image = images[activeIndex]!;
|
||||
const viewport = useViewportSize();
|
||||
const compactViewport = viewport.width <= 720;
|
||||
const similarColumnCount = viewport.width < 520
|
||||
? 2
|
||||
: viewport.width < 960
|
||||
? 3
|
||||
: viewport.width < 1280
|
||||
? 4
|
||||
: 5;
|
||||
const mediaDimensions = viewerMediaDimensions(image, viewport);
|
||||
const viewportRender = viewportRenderDimensions(image, viewport);
|
||||
const viewportBitmapCapable = supportsViewerViewportBitmaps();
|
||||
@@ -199,6 +218,14 @@ export function ImageViewer({
|
||||
const sourceUpgradeTimerRef = useRef(0);
|
||||
const scrollTopRef = useRef(0);
|
||||
const scrollPageRef = useRef<ViewerPage>("image");
|
||||
const viewerScrollSnapshotRef = useRef<ViewerScrollSnapshot>({
|
||||
imageId: image.id,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
detailsTop: viewport.height,
|
||||
scrollTop: 0,
|
||||
page: "image",
|
||||
});
|
||||
const wheelZoomBlockedUntilRef = useRef(0);
|
||||
const displayedOriginalsRef = useRef(new Set<string>());
|
||||
const transformFrameRef = useRef(0);
|
||||
@@ -358,6 +385,21 @@ export function ImageViewer({
|
||||
if (scrollPageRef.current === nextPage) return;
|
||||
scrollPageRef.current = nextPage;
|
||||
setScrollPage(nextPage);
|
||||
return nextPage;
|
||||
};
|
||||
|
||||
const rememberViewerScroll = (
|
||||
viewer: HTMLDivElement,
|
||||
page = scrollPageRef.current,
|
||||
) => {
|
||||
viewerScrollSnapshotRef.current = {
|
||||
imageId: image.id,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
detailsTop: detailsSectionRef.current?.offsetTop ?? viewer.clientHeight,
|
||||
scrollTop: viewer.scrollTop,
|
||||
page,
|
||||
};
|
||||
};
|
||||
|
||||
const forceViewerScroll = (top: number) => {
|
||||
@@ -368,7 +410,8 @@ export function ImageViewer({
|
||||
viewer.scrollTop = top;
|
||||
viewer.style.scrollBehavior = previousBehavior;
|
||||
scrollTopRef.current = viewer.scrollTop;
|
||||
syncScrollPage(viewer.scrollTop);
|
||||
const page = syncScrollPage(viewer.scrollTop) ?? scrollPageRef.current;
|
||||
rememberViewerScroll(viewer, page);
|
||||
};
|
||||
|
||||
const constrainTransform = (
|
||||
@@ -603,7 +646,6 @@ export function ImageViewer({
|
||||
setDragging(false);
|
||||
commitTransform(DEFAULT_TRANSFORM);
|
||||
wheelZoomBlockedUntilRef.current = 0;
|
||||
forceViewerScroll(0);
|
||||
setThumbnailState({
|
||||
id: image.id,
|
||||
loaded: getViewerThumbnailStatus(image) === "ready",
|
||||
@@ -629,15 +671,39 @@ export function ImageViewer({
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewer = dialogRef.current;
|
||||
if (!viewer) return;
|
||||
const previous = viewerScrollSnapshotRef.current;
|
||||
const imageChanged = previous.imageId !== image.id;
|
||||
const viewportChanged = previous.viewportWidth !== viewport.width
|
||||
|| previous.viewportHeight !== viewport.height;
|
||||
|
||||
if (transformRef.current.scale > MIN_SCALE) {
|
||||
forceViewerScroll(0);
|
||||
if (imageChanged || viewportChanged) forceViewerScroll(0);
|
||||
commitTransform(constrainTransform(transformRef.current));
|
||||
return;
|
||||
} else if (imageChanged) {
|
||||
// Navigation starts each image at the top, while a resize keeps the current
|
||||
// position inside the image or details page.
|
||||
forceViewerScroll(0);
|
||||
} else if (viewportChanged) {
|
||||
const previousDetailsTop = Math.max(
|
||||
1,
|
||||
previous.detailsTop || previous.viewportHeight,
|
||||
);
|
||||
const nextDetailsTop = detailsSectionRef.current?.offsetTop ?? viewport.height;
|
||||
const wasInDetails = previous.page === "details"
|
||||
|| previous.scrollTop >= previousDetailsTop - VIEWER_SCROLL_EPSILON;
|
||||
const nextTop = wasInDetails
|
||||
? nextDetailsTop + Math.max(0, previous.scrollTop - previousDetailsTop)
|
||||
: previous.page === "transition"
|
||||
? nextDetailsTop * clamp(previous.scrollTop / previousDetailsTop, 0, 1)
|
||||
: 0;
|
||||
const maximumScroll = Math.max(0, viewer.scrollHeight - viewer.clientHeight);
|
||||
forceViewerScroll(clamp(nextTop, 0, maximumScroll));
|
||||
} else {
|
||||
rememberViewerScroll(viewer);
|
||||
}
|
||||
if (scrollPageRef.current === "details") {
|
||||
forceViewerScroll(detailsSectionRef.current?.offsetTop ?? viewport.height);
|
||||
}
|
||||
}, [viewport.width, viewport.height]);
|
||||
}, [image.id, viewport.width, viewport.height]);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement instanceof HTMLElement
|
||||
@@ -680,6 +746,15 @@ export function ImageViewer({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
scrollPage === "image"
|
||||
|| scrollPageRef.current === "image"
|
||||
|| similarActive
|
||||
) return;
|
||||
onSearchSimilar(image);
|
||||
}, [image, onSearchSimilar, scrollPage, similarActive]);
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
try {
|
||||
if (document.fullscreenElement === dialogRef.current) {
|
||||
@@ -1228,7 +1303,8 @@ export function ImageViewer({
|
||||
const detailsTop = detailsSectionRef.current?.offsetTop ?? viewer.clientHeight;
|
||||
viewer.scrollTop = clamp(start.scrollTop - deltaY, 0, detailsTop);
|
||||
scrollTopRef.current = viewer.scrollTop;
|
||||
syncScrollPage(viewer.scrollTop);
|
||||
const page = syncScrollPage(viewer.scrollTop) ?? scrollPageRef.current;
|
||||
rememberViewerScroll(viewer, page);
|
||||
if (transformRef.current.x || transformRef.current.y) stageTransform(DEFAULT_TRANSFORM);
|
||||
return;
|
||||
}
|
||||
@@ -1530,7 +1606,8 @@ export function ImageViewer({
|
||||
return;
|
||||
}
|
||||
scrollTopRef.current = nextTop;
|
||||
syncScrollPage(nextTop);
|
||||
const page = syncScrollPage(nextTop) ?? scrollPageRef.current;
|
||||
rememberViewerScroll(viewer, page);
|
||||
if (nextTop > VIEWER_SCROLL_EPSILON || previousTop > VIEWER_SCROLL_EPSILON) {
|
||||
wheelZoomBlockedUntilRef.current = performance.now() + WHEEL_HANDOFF_DELAY_MS;
|
||||
}
|
||||
@@ -1582,15 +1659,6 @@ export function ImageViewer({
|
||||
} as CSSProperties;
|
||||
const mediaWidth = mediaDimensions.width;
|
||||
const mediaHeight = mediaDimensions.height;
|
||||
const fileExtension = image.name.includes(".")
|
||||
? image.name.split(".").pop()?.toLocaleUpperCase() ?? "图片"
|
||||
: "图片";
|
||||
const orientation = image.width === image.height
|
||||
? "方形"
|
||||
: image.width > image.height
|
||||
? "横向"
|
||||
: "竖向";
|
||||
const megapixels = image.width * image.height / 1_000_000;
|
||||
const nativeOriginalActive = currentFullResolution.loaded
|
||||
&& liveTransform.scale > MIN_SCALE;
|
||||
const displaySource = resolveViewerDisplaySource({
|
||||
@@ -1610,6 +1678,71 @@ export function ImageViewer({
|
||||
aspectRatio: `${image.width} / ${image.height}`,
|
||||
transform: `translate3d(${liveTransform.x}px, ${liveTransform.y}px, 0) scale(${liveTransform.scale})`,
|
||||
} as CSSProperties;
|
||||
const renderActionSuite = (details = false) => (
|
||||
<div
|
||||
className={`viewer-header-actions${details ? " viewer-details-toolbar" : ""}`}
|
||||
role="group"
|
||||
aria-label={details ? "图片详情操作" : "图片查看操作"}
|
||||
>
|
||||
{details && (
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-control viewer-details-return"
|
||||
onClick={() => scrollToImage(true)}
|
||||
aria-label="返回图片"
|
||||
title="返回图片"
|
||||
>
|
||||
<ChevronUp size={19} strokeWidth={1.8} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`viewer-control viewer-similar-trigger${similarActive ? " is-active" : ""}`}
|
||||
onClick={() => {
|
||||
onSearchSimilar(image);
|
||||
scrollToSimilar();
|
||||
}}
|
||||
aria-label="以图搜图,查找相似图片"
|
||||
aria-pressed={similarActive}
|
||||
data-state={similarActive ? "active" : "idle"}
|
||||
title="以图搜图"
|
||||
>
|
||||
<ScanSearch size={19} />
|
||||
</button>
|
||||
<a
|
||||
className="viewer-control"
|
||||
href={viewerOriginalUrl(image)}
|
||||
download={image.name}
|
||||
aria-label="下载原图"
|
||||
title="下载原图"
|
||||
>
|
||||
<Download size={19} />
|
||||
</a>
|
||||
{fullscreenAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`viewer-control ${details ? "viewer-details-fullscreen" : "viewer-fullscreen"}${fullscreen ? " is-active" : ""}`}
|
||||
onClick={() => void toggleFullscreen()}
|
||||
aria-label={fullscreen ? "退出全屏" : "进入全屏"}
|
||||
aria-pressed={fullscreen}
|
||||
data-state={fullscreen ? "active" : "idle"}
|
||||
title={fullscreen ? "退出全屏" : "全屏查看"}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={19} /> : <Maximize2 size={19} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={details ? undefined : closeButtonRef}
|
||||
type="button"
|
||||
className={`viewer-control ${details ? "viewer-details-close" : "viewer-close"}`}
|
||||
onClick={onClose}
|
||||
aria-label="关闭查看器"
|
||||
title="关闭 (Esc)"
|
||||
>
|
||||
<X size={21} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const viewer = (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
@@ -1648,51 +1781,7 @@ export function ImageViewer({
|
||||
<div className="viewer-backdrop" aria-hidden="true" />
|
||||
|
||||
<header className="viewer-header">
|
||||
<div className="viewer-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`viewer-control viewer-similar-trigger${similarActive ? " is-active" : ""}`}
|
||||
onClick={() => {
|
||||
onSearchSimilar(image);
|
||||
scrollToSimilar();
|
||||
}}
|
||||
aria-label="以图搜图,查找相似图片"
|
||||
aria-pressed={similarActive}
|
||||
title="以图搜图"
|
||||
>
|
||||
<ScanSearch size={19} />
|
||||
</button>
|
||||
<a
|
||||
className="viewer-control"
|
||||
href={viewerOriginalUrl(image)}
|
||||
download={image.name}
|
||||
aria-label="下载原图"
|
||||
title="下载原图"
|
||||
>
|
||||
<Download size={19} />
|
||||
</a>
|
||||
{fullscreenAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-control viewer-fullscreen"
|
||||
onClick={() => void toggleFullscreen()}
|
||||
aria-label={fullscreen ? "退出全屏" : "进入全屏"}
|
||||
title={fullscreen ? "退出全屏" : "全屏查看"}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={19} /> : <Maximize2 size={19} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
className="viewer-control viewer-close"
|
||||
onClick={onClose}
|
||||
aria-label="关闭查看器"
|
||||
title="关闭 (Esc)"
|
||||
>
|
||||
<X size={21} />
|
||||
</button>
|
||||
</div>
|
||||
{renderActionSuite()}
|
||||
</header>
|
||||
|
||||
<div
|
||||
@@ -1862,131 +1951,56 @@ export function ImageViewer({
|
||||
<h2 id="image-viewer-details-title">图片详情</h2>
|
||||
<span>{displayPosition} / {total}</span>
|
||||
</div>
|
||||
<div className="viewer-details-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-control viewer-details-return"
|
||||
onClick={() => scrollToImage(true)}
|
||||
aria-label="返回图片"
|
||||
title="返回图片"
|
||||
>
|
||||
<ChevronUp size={20} strokeWidth={1.8} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-control viewer-details-close"
|
||||
onClick={onClose}
|
||||
aria-label="关闭查看器"
|
||||
title="关闭 (Esc)"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{renderActionSuite(true)}
|
||||
</header>
|
||||
|
||||
<div className="viewer-details-content">
|
||||
<article className="viewer-details-summary">
|
||||
<img
|
||||
key={`details-thumbnail-${image.id}`}
|
||||
src={viewerThumbnailUrl(image)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
<div>
|
||||
<span className="viewer-details-kind">{orientation} · {fileExtension}</span>
|
||||
<h3 title={image.name}>{image.name}</h3>
|
||||
<p>{image.width.toLocaleString()} × {image.height.toLocaleString()} 像素</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section className="viewer-details-section" aria-labelledby="viewer-file-info-title">
|
||||
<h3 id="viewer-file-info-title">文件信息</h3>
|
||||
<dl className="viewer-details-grid">
|
||||
<div>
|
||||
<dt>分辨率</dt>
|
||||
<dd>{image.width.toLocaleString()} × {image.height.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>像素</dt>
|
||||
<dd>{megapixels >= 1 ? `${megapixels.toFixed(1)} MP` : `${Math.round(megapixels * 1000)} KP`}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>格式</dt>
|
||||
<dd>{fileExtension}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>图库位置</dt>
|
||||
<dd>{displayPosition} / {total}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<a
|
||||
className="viewer-details-download"
|
||||
href={viewerOriginalUrl(image)}
|
||||
download={image.name}
|
||||
>
|
||||
<Download size={19} />
|
||||
<span>
|
||||
<strong>下载原图</strong>
|
||||
<small>保留原始尺寸与格式</small>
|
||||
</span>
|
||||
<ChevronRight size={18} />
|
||||
</a>
|
||||
<PhotoInformation image={image} />
|
||||
|
||||
<section
|
||||
ref={similarSectionRef}
|
||||
className="viewer-similar-section"
|
||||
aria-labelledby="viewer-similar-title"
|
||||
data-similar-active={similarActive}
|
||||
>
|
||||
<div className="viewer-similar-heading">
|
||||
<div>
|
||||
<h3 id="viewer-similar-title">相似图片</h3>
|
||||
<p>在图库中查找构图、色彩和纹理相近的图片</p>
|
||||
</div>
|
||||
<ScanSearch size={20} aria-hidden="true" />
|
||||
</div>
|
||||
ref={similarSectionRef}
|
||||
className={`viewer-similar-section${similarActive ? "" : " is-idle"}`}
|
||||
aria-labelledby={similarActive ? "viewer-similar-title" : undefined}
|
||||
data-similar-active={similarActive}
|
||||
>
|
||||
{similarActive && (
|
||||
<>
|
||||
<div className="viewer-similar-heading">
|
||||
<div>
|
||||
<h3 id="viewer-similar-title">相似图片</h3>
|
||||
</div>
|
||||
<ScanSearch size={20} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{!similarActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-similar-search-button"
|
||||
onClick={() => onSearchSimilar(image)}
|
||||
>
|
||||
<ScanSearch size={18} />
|
||||
<span>以图搜图</span>
|
||||
<ChevronRight size={17} />
|
||||
</button>
|
||||
) : similarLoading && !similarImages.length ? (
|
||||
<SimilarImageSkeleton />
|
||||
) : similarError && !similarImages.length ? (
|
||||
<div className="viewer-similar-error" role="alert">
|
||||
<span>{similarError}</span>
|
||||
<button type="button" onClick={() => onSearchSimilar(image)}>
|
||||
<RefreshCw size={15} />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : similarImages.length ? (
|
||||
<SimilarImageMasonry
|
||||
images={similarImages}
|
||||
total={similarTotal}
|
||||
columnCount={compactViewport ? 2 : 3}
|
||||
hasMore={similarHasMore}
|
||||
loadingMore={similarLoadingMore}
|
||||
error={similarError}
|
||||
onLoadMore={onLoadMoreSimilar}
|
||||
onOpen={onOpenSimilar}
|
||||
/>
|
||||
) : (
|
||||
<div className="viewer-similar-empty">
|
||||
<ScanSearch size={22} />
|
||||
<span>暂时没有找到相似图片</span>
|
||||
</div>
|
||||
)}
|
||||
{similarLoading && !similarImages.length ? (
|
||||
<SimilarImageSkeleton columnCount={similarColumnCount} />
|
||||
) : similarError && !similarImages.length ? (
|
||||
<div className="viewer-similar-error" role="alert">
|
||||
<span>{similarError}</span>
|
||||
<button type="button" onClick={() => onSearchSimilar(image)}>
|
||||
<RefreshCw size={15} />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : similarImages.length ? (
|
||||
<SimilarImageMasonry
|
||||
images={similarImages}
|
||||
total={similarTotal}
|
||||
columnCount={similarColumnCount}
|
||||
hasMore={similarHasMore}
|
||||
loadingMore={similarLoadingMore}
|
||||
error={similarError}
|
||||
onLoadMore={onLoadMoreSimilar}
|
||||
onOpen={onOpenSimilar}
|
||||
/>
|
||||
) : (
|
||||
<div className="viewer-similar-empty">
|
||||
<ScanSearch size={22} />
|
||||
<span>暂时没有找到相似图片</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { memo } from "preact/compat";
|
||||
import type { PhotoHistogram as HistogramData } from "./types";
|
||||
|
||||
const BOTTOM = 96;
|
||||
const CHANNELS = [
|
||||
{ key: "luminance", label: "亮度" },
|
||||
{ key: "red", label: "红" },
|
||||
{ key: "green", label: "绿" },
|
||||
{ key: "blue", label: "蓝" },
|
||||
] as const;
|
||||
|
||||
function area(values: number[], maximum: number): string {
|
||||
const points = values.map((value, index) => {
|
||||
const y = BOTTOM - value / maximum * (BOTTOM - 5);
|
||||
return `L${index} ${y.toFixed(2)}`;
|
||||
});
|
||||
return `M0 ${BOTTOM} ${points.join(" ")} L255 ${BOTTOM} Z`;
|
||||
}
|
||||
|
||||
export function HistogramLegend() {
|
||||
return (
|
||||
<div className="viewer-histogram-legend" aria-label="直方图通道">
|
||||
{CHANNELS.map(({ key, label }) => (
|
||||
<span key={key} data-channel={key}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PhotoHistogram = memo(function PhotoHistogram({
|
||||
histogram,
|
||||
imageId,
|
||||
}: {
|
||||
histogram: HistogramData;
|
||||
imageId: string;
|
||||
}) {
|
||||
const maximum = Math.max(
|
||||
1,
|
||||
...histogram.red,
|
||||
...histogram.green,
|
||||
...histogram.blue,
|
||||
...histogram.luminance,
|
||||
);
|
||||
const titleId = `photo-histogram-${imageId}`;
|
||||
|
||||
return (
|
||||
<div className="viewer-histogram-card">
|
||||
<svg
|
||||
className="viewer-histogram"
|
||||
viewBox="0 0 255 100"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<title id={titleId}>照片的红、绿、蓝和亮度分布直方图</title>
|
||||
<g className="viewer-histogram-grid" aria-hidden="true">
|
||||
<path d="M0 24.5H255 M0 48.5H255 M0 72.5H255" />
|
||||
<path d="M63.5 0V96 M127.5 0V96 M191.5 0V96" />
|
||||
</g>
|
||||
{CHANNELS.map(({ key }) => (
|
||||
<path
|
||||
key={key}
|
||||
className={`viewer-histogram-channel viewer-histogram-${key}`}
|
||||
d={area(histogram[key], maximum)}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div className="viewer-histogram-axis" aria-hidden="true">
|
||||
<span>暗部</span>
|
||||
<span>中间调</span>
|
||||
<span>高光</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
.viewer-photo-information {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.viewer-details-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.viewer-details-section-heading {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.viewer-details-section-heading h3,
|
||||
.viewer-details-section-heading > span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.viewer-details-section-heading h3 {
|
||||
color: rgba(250, 252, 251, .94);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.viewer-details-section-heading > span {
|
||||
color: rgba(225, 235, 231, .52);
|
||||
font-size: 10.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.viewer-image-information-panel {
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 0 0, rgba(82, 151, 141, .11), transparent 38%),
|
||||
rgba(21, 27, 24, .98);
|
||||
border: 1px solid rgba(255, 255, 255, .11);
|
||||
border-radius: 18px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, .025),
|
||||
0 12px 34px rgba(0, 0, 0, .1);
|
||||
}
|
||||
|
||||
.viewer-image-information-overview,
|
||||
.viewer-image-information-metadata,
|
||||
.viewer-image-information-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.viewer-image-information-metadata {
|
||||
border-top: 1px solid rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
||||
.viewer-image-information-group {
|
||||
padding: 13px 15px;
|
||||
}
|
||||
|
||||
.viewer-image-information-group + .viewer-image-information-group,
|
||||
.viewer-image-information-overview + .viewer-image-information-group,
|
||||
.viewer-image-information-loading,
|
||||
.viewer-image-information-empty {
|
||||
border-top: 1px solid rgba(255, 255, 255, .065);
|
||||
}
|
||||
|
||||
.viewer-image-information-group h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(235, 242, 239, .72);
|
||||
font-size: 11.5px;
|
||||
font-weight: 680;
|
||||
letter-spacing: .025em;
|
||||
}
|
||||
|
||||
.viewer-image-information-group h4::before {
|
||||
width: 3px;
|
||||
height: 11px;
|
||||
flex: 0 0 auto;
|
||||
content: "";
|
||||
background: rgba(143, 210, 201, .72);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.viewer-image-information-group-heading {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.viewer-image-information-group-heading h4 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.viewer-image-information-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.viewer-image-information-list > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .06);
|
||||
}
|
||||
|
||||
.viewer-image-information-basic > [data-info-kind="filename"],
|
||||
.viewer-image-information-basic > [data-info-kind="captured"] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.viewer-image-information-list dt {
|
||||
color: rgba(222, 233, 229, .61);
|
||||
font-size: 11px;
|
||||
font-weight: 620;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-image-information-list dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: rgba(249, 251, 250, .93);
|
||||
font-size: 13px;
|
||||
font-weight: 540;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-image-information-basic > [data-info-kind="filename"] dd {
|
||||
color: #fff;
|
||||
font-size: 15.5px;
|
||||
font-weight: 680;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
|
||||
.viewer-image-information-exposure {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.viewer-image-information-exposure > div {
|
||||
--exposure-accent: 143, 210, 201;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 10px 12px 11px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(var(--exposure-accent), .105), transparent 62%),
|
||||
rgba(255, 255, 255, .025);
|
||||
border: 1px solid rgba(var(--exposure-accent), .22);
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.viewer-image-information-exposure > div::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 13px;
|
||||
left: 13px;
|
||||
height: 2px;
|
||||
content: "";
|
||||
background: linear-gradient(90deg, rgba(var(--exposure-accent), .78), transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.viewer-image-information-exposure > [data-exposure-kind="aperture"] {
|
||||
--exposure-accent: 222, 174, 101;
|
||||
}
|
||||
|
||||
.viewer-image-information-exposure > [data-exposure-kind="shutter"] {
|
||||
--exposure-accent: 113, 162, 255;
|
||||
}
|
||||
|
||||
.viewer-image-information-exposure > [data-exposure-kind="iso"] {
|
||||
--exposure-accent: 190, 143, 224;
|
||||
}
|
||||
|
||||
.viewer-image-information-exposure dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: rgba(232, 239, 236, .72);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
.viewer-image-information-exposure dt::before {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
flex: 0 0 auto;
|
||||
content: "";
|
||||
background: rgb(var(--exposure-accent));
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px rgba(var(--exposure-accent), .28);
|
||||
}
|
||||
|
||||
.viewer-image-information-exposure dd {
|
||||
margin: 5px 0 0;
|
||||
overflow: hidden;
|
||||
color: #f9fbfa;
|
||||
font-size: clamp(17px, 1.55vw, 21px);
|
||||
font-weight: 680;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -.025em;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-histogram-card {
|
||||
overflow: hidden;
|
||||
padding: 10px 12px 7px;
|
||||
background: rgba(8, 12, 10, .28);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.viewer-histogram {
|
||||
width: 100%;
|
||||
height: clamp(104px, 13vw, 126px);
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.viewer-histogram-grid {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, .075);
|
||||
stroke-width: .55;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.viewer-histogram-channel {
|
||||
stroke-width: .8;
|
||||
vector-effect: non-scaling-stroke;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.viewer-histogram-luminance {
|
||||
fill: rgba(217, 224, 221, .12);
|
||||
stroke: rgba(226, 232, 229, .25);
|
||||
}
|
||||
|
||||
.viewer-histogram-red {
|
||||
fill: rgba(246, 91, 83, .24);
|
||||
stroke: rgba(255, 119, 111, .68);
|
||||
}
|
||||
|
||||
.viewer-histogram-green {
|
||||
fill: rgba(63, 205, 124, .22);
|
||||
stroke: rgba(100, 226, 151, .62);
|
||||
}
|
||||
|
||||
.viewer-histogram-blue {
|
||||
fill: rgba(75, 135, 255, .25);
|
||||
stroke: rgba(111, 162, 255, .7);
|
||||
}
|
||||
|
||||
.viewer-histogram-axis {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 4px;
|
||||
color: rgba(239, 243, 241, .43);
|
||||
border-top: 1px solid rgba(255, 255, 255, .055);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.viewer-histogram-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(239, 243, 241, .58);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.viewer-histogram-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.viewer-histogram-legend span::before {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
content: "";
|
||||
background: #d7dfdc;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.viewer-histogram-legend span[data-channel="red"]::before { background: #f65b53; }
|
||||
.viewer-histogram-legend span[data-channel="green"]::before { background: #3fcd7c; }
|
||||
.viewer-histogram-legend span[data-channel="blue"]::before { background: #4b87ff; }
|
||||
|
||||
.viewer-histogram-card.is-loading {
|
||||
min-height: 148px;
|
||||
display: grid;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.viewer-photo-data-skeleton,
|
||||
.viewer-image-information-value-skeleton,
|
||||
.viewer-image-information-loading span {
|
||||
background: linear-gradient(105deg, #202523 24%, #2c332f 43%, #202523 62%);
|
||||
background-size: 220% 100%;
|
||||
animation: skeleton-shimmer 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.viewer-photo-data-skeleton {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.viewer-image-information-value-skeleton {
|
||||
width: min(72px, 60%);
|
||||
height: 10px;
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.viewer-image-information-loading {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 15px 16px;
|
||||
}
|
||||
|
||||
.viewer-image-information-loading span {
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.viewer-image-information-loading span:nth-child(1) { width: 82%; }
|
||||
.viewer-image-information-loading span:nth-child(2) { width: 64%; }
|
||||
.viewer-image-information-loading span:nth-child(3) { width: 74%; }
|
||||
|
||||
.viewer-image-information-empty,
|
||||
.viewer-photo-data-error {
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
color: rgba(239, 243, 241, .45);
|
||||
background: rgba(255, 255, 255, .035);
|
||||
border: 1px solid rgba(255, 255, 255, .065);
|
||||
border-radius: 11px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.viewer-image-information-empty {
|
||||
background: transparent;
|
||||
border-width: 1px 0 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.viewer-photo-data-error {
|
||||
color: #ffd9d4;
|
||||
}
|
||||
|
||||
.viewer-image-information-histogram .viewer-photo-data-error {
|
||||
min-height: 148px;
|
||||
}
|
||||
|
||||
.viewer-photo-data-error button {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
color: inherit;
|
||||
background: rgba(124, 58, 49, .32);
|
||||
border: 1px solid rgba(255, 187, 177, .16);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.viewer-image-information-overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, .86fr) minmax(0, 1.14fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.viewer-image-information-overview > .viewer-image-information-group +
|
||||
.viewer-image-information-group {
|
||||
align-self: stretch;
|
||||
border-top: 0;
|
||||
border-left: 1px solid rgba(255, 255, 255, .065);
|
||||
}
|
||||
|
||||
.viewer-image-information-metadata {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr);
|
||||
}
|
||||
|
||||
.viewer-image-information-metadata > .viewer-image-information-group:only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.viewer-image-information-metadata > .viewer-image-information-group +
|
||||
.viewer-image-information-group {
|
||||
border-top: 0;
|
||||
border-left: 1px solid rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
||||
.viewer-image-information-histogram .viewer-histogram {
|
||||
height: clamp(160px, 14vw, 196px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.viewer-image-information-panel { border-radius: 16px; }
|
||||
.viewer-image-information-group { padding: 12px; }
|
||||
.viewer-image-information-list { grid-template-columns: minmax(0, 1fr); }
|
||||
.viewer-image-information-basic > [data-info-kind="filename"],
|
||||
.viewer-image-information-basic > [data-info-kind="captured"] {
|
||||
grid-column: auto;
|
||||
}
|
||||
.viewer-image-information-exposure {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.viewer-image-information-exposure > div { padding: 10px 11px 11px; }
|
||||
.viewer-image-information-exposure dd { font-size: 18px; }
|
||||
.viewer-image-information-loading { padding: 12px; }
|
||||
.viewer-histogram-card { padding: 9px 10px 6px; }
|
||||
.viewer-histogram-card.is-loading { min-height: 130px; }
|
||||
.viewer-histogram { height: 104px; }
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { memo } from "preact/compat";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { getPhotoDetails } from "./api";
|
||||
import { formatFileSize, formatPixelCount } from "./format";
|
||||
import { RefreshCw } from "./icons";
|
||||
import { HistogramLegend, PhotoHistogram } from "./PhotoHistogram";
|
||||
import type {
|
||||
GalleryImage,
|
||||
PhotoDetails,
|
||||
PhotoExifField,
|
||||
} from "./types";
|
||||
|
||||
type PhotoInformationProps = {
|
||||
image: GalleryImage;
|
||||
};
|
||||
|
||||
type ShootingParameter = {
|
||||
kind: "focal" | "aperture" | "shutter" | "iso";
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type InformationField = {
|
||||
label: string;
|
||||
value: string | null;
|
||||
kind?: string;
|
||||
};
|
||||
|
||||
type ImageInformationPanelProps = PhotoInformationProps & {
|
||||
details: PhotoDetails | null;
|
||||
loading: boolean;
|
||||
errorMessage: string | null;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
type DetailsState =
|
||||
| { imageId: string; status: "loading" }
|
||||
| { imageId: string; status: "ready"; details: PhotoDetails }
|
||||
| { imageId: string; status: "error"; message: string };
|
||||
|
||||
const DEVICE_EXIF_LABELS = ["相机", "镜头", "焦距", "35mm 等效"] as const;
|
||||
const GROUPED_EXIF_LABELS: ReadonlySet<string> = new Set([
|
||||
"拍摄时间",
|
||||
"色彩空间",
|
||||
"相机",
|
||||
"镜头",
|
||||
"焦距",
|
||||
"35mm 等效",
|
||||
"光圈",
|
||||
"快门",
|
||||
"ISO",
|
||||
]);
|
||||
|
||||
function shootingParameter(
|
||||
field: PhotoExifField | undefined,
|
||||
kind: ShootingParameter["kind"],
|
||||
label: string,
|
||||
format: (value: string) => string = (value) => value,
|
||||
): ShootingParameter | null {
|
||||
return field ? { kind, label, value: format(field.value) } : null;
|
||||
}
|
||||
|
||||
function InformationList({
|
||||
fields,
|
||||
className = "",
|
||||
}: {
|
||||
fields: InformationField[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<dl className={`viewer-image-information-list ${className}`.trim()}>
|
||||
{fields.map((field) => (
|
||||
<div
|
||||
key={field.kind ?? `${field.label}-${field.value}`}
|
||||
data-info-kind={field.kind}
|
||||
>
|
||||
<dt>{field.label}</dt>
|
||||
<dd title={field.value ?? undefined}>
|
||||
{field.value ?? <span className="viewer-image-information-value-skeleton" />}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function InformationSection({
|
||||
title,
|
||||
fields,
|
||||
listClassName,
|
||||
}: {
|
||||
title: string;
|
||||
fields: InformationField[];
|
||||
listClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="viewer-image-information-group">
|
||||
<h4>{title}</h4>
|
||||
<InformationList fields={fields} className={listClassName} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageInformationPanel({
|
||||
image,
|
||||
details,
|
||||
loading,
|
||||
errorMessage,
|
||||
onRetry,
|
||||
}: ImageInformationPanelProps) {
|
||||
const fileExtension = image.name.includes(".")
|
||||
? image.name.split(".").pop()?.toLocaleUpperCase() ?? "图片"
|
||||
: "图片";
|
||||
const orientation = image.width === image.height
|
||||
? "方形"
|
||||
: image.width > image.height
|
||||
? "横向"
|
||||
: "竖向";
|
||||
const megapixels = image.width * image.height / 1_000_000;
|
||||
const exif = details?.exif ?? [];
|
||||
const fieldByLabel = new Map(exif.map((field) => [field.label, field]));
|
||||
const capturedAt = fieldByLabel.get("拍摄时间");
|
||||
const colorSpace = fieldByLabel.get("色彩空间");
|
||||
const shootingFocal = fieldByLabel.get("35mm 等效") ?? fieldByLabel.get("焦距");
|
||||
const shooting = [
|
||||
shootingParameter(
|
||||
shootingFocal,
|
||||
"focal",
|
||||
shootingFocal?.label === "35mm 等效" ? "等效焦距" : "焦距",
|
||||
),
|
||||
shootingParameter(fieldByLabel.get("光圈"), "aperture", "光圈"),
|
||||
shootingParameter(fieldByLabel.get("快门"), "shutter", "快门速度"),
|
||||
shootingParameter(
|
||||
fieldByLabel.get("ISO"),
|
||||
"iso",
|
||||
"感光度",
|
||||
(value) => `ISO ${value.replace(/^ISO\s*/i, "")}`,
|
||||
),
|
||||
].filter((parameter): parameter is ShootingParameter => parameter !== null);
|
||||
const devices = DEVICE_EXIF_LABELS.flatMap((label) => {
|
||||
const field = fieldByLabel.get(label);
|
||||
return field ? [field] : [];
|
||||
});
|
||||
const additional = exif.filter((field) => !GROUPED_EXIF_LABELS.has(field.label));
|
||||
const basicInformation: InformationField[] = [
|
||||
{ label: "文件名", value: image.name, kind: "filename" },
|
||||
{ label: "格式", value: fileExtension, kind: "format" },
|
||||
{
|
||||
label: "尺寸",
|
||||
value: `${image.width.toLocaleString()} × ${image.height.toLocaleString()}`,
|
||||
kind: "dimensions",
|
||||
},
|
||||
{
|
||||
label: "文件大小",
|
||||
value: details ? formatFileSize(details.fileSize) : errorMessage ? "读取失败" : null,
|
||||
kind: "file-size",
|
||||
},
|
||||
{ label: "像素", value: formatPixelCount(megapixels), kind: "pixels" },
|
||||
...(colorSpace
|
||||
? [{ label: "色彩空间", value: colorSpace.value, kind: "color-space" }]
|
||||
: []),
|
||||
{ label: "方向", value: orientation, kind: "orientation" },
|
||||
...(capturedAt
|
||||
? [{ label: "拍摄时间", value: capturedAt.value, kind: "captured" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="viewer-image-information-panel">
|
||||
<div className="viewer-image-information-overview">
|
||||
<InformationSection
|
||||
title="基本信息"
|
||||
fields={basicInformation}
|
||||
listClassName="viewer-image-information-basic"
|
||||
/>
|
||||
|
||||
<section className="viewer-image-information-group viewer-image-information-histogram">
|
||||
<div className="viewer-image-information-group-heading">
|
||||
<h4>影调分布</h4>
|
||||
<HistogramLegend />
|
||||
</div>
|
||||
{details ? (
|
||||
<PhotoHistogram histogram={details.histogram} imageId={image.id} />
|
||||
) : errorMessage ? (
|
||||
<PhotoDataError message={errorMessage} onRetry={onRetry} />
|
||||
) : (
|
||||
<div className="viewer-histogram-card is-loading" aria-busy="true">
|
||||
<div className="viewer-photo-data-skeleton" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{shooting.length > 0 && (
|
||||
<section className="viewer-image-information-group">
|
||||
<h4>拍摄参数</h4>
|
||||
<dl className="viewer-image-information-exposure">
|
||||
{shooting.map((parameter) => (
|
||||
<div key={parameter.kind} data-exposure-kind={parameter.kind}>
|
||||
<dt>{parameter.label}</dt>
|
||||
<dd title={`${parameter.label}:${parameter.value}`}>{parameter.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(devices.length > 0 || additional.length > 0) && (
|
||||
<div className="viewer-image-information-metadata">
|
||||
{devices.length > 0 && (
|
||||
<InformationSection title="设备信息" fields={devices} />
|
||||
)}
|
||||
|
||||
{additional.length > 0 && (
|
||||
<InformationSection title="其他信息" fields={additional} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="viewer-image-information-loading" aria-label="正在读取拍摄信息">
|
||||
<span /><span /><span />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{details && exif.length === 0 && (
|
||||
<div className="viewer-image-information-empty">无拍摄参数</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoDataError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="viewer-photo-data-error" role="alert">
|
||||
<span>{message}</span>
|
||||
<button type="button" onClick={onRetry}>
|
||||
<RefreshCw size={15} />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function usePhotoDetails(imageId: string) {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [state, setState] = useState<DetailsState>({
|
||||
imageId,
|
||||
status: "loading",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setState({ imageId, status: "loading" });
|
||||
void getPhotoDetails(imageId, controller.signal)
|
||||
.then((details) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setState({ imageId, status: "ready", details });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setState({
|
||||
imageId,
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "无法读取照片信息",
|
||||
});
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [imageId, attempt]);
|
||||
|
||||
const currentState = state.imageId === imageId
|
||||
? state
|
||||
: { imageId, status: "loading" } as const;
|
||||
return {
|
||||
details: currentState.status === "ready" ? currentState.details : null,
|
||||
loading: currentState.status === "loading",
|
||||
errorMessage: currentState.status === "error" ? currentState.message : null,
|
||||
retry: () => setAttempt((value) => value + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function informationStatus(
|
||||
details: PhotoDetails | null,
|
||||
loading: boolean,
|
||||
errorMessage: string | null,
|
||||
): string {
|
||||
if (errorMessage) return "部分信息读取失败";
|
||||
if (loading) return "正在读取 EXIF 与直方图";
|
||||
return details?.exif.length ? "EXIF · 直方图" : "基本资料 · 直方图";
|
||||
}
|
||||
|
||||
export const PhotoInformation = memo(function PhotoInformation({
|
||||
image,
|
||||
}: PhotoInformationProps) {
|
||||
const { details, loading, errorMessage, retry } = usePhotoDetails(image.id);
|
||||
|
||||
return (
|
||||
<div className="viewer-photo-information">
|
||||
<section
|
||||
className="viewer-details-section"
|
||||
aria-labelledby="viewer-image-information-title"
|
||||
>
|
||||
<div className="viewer-details-section-heading">
|
||||
<h3 id="viewer-image-information-title">图片信息</h3>
|
||||
<span>{informationStatus(details, loading, errorMessage)}</span>
|
||||
</div>
|
||||
<ImageInformationPanel
|
||||
image={image}
|
||||
details={details}
|
||||
loading={loading}
|
||||
errorMessage={errorMessage}
|
||||
onRetry={retry}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CSSProperties } from "preact";
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
|
||||
import { LoaderCircle, RefreshCw } from "./icons";
|
||||
import type { GalleryImage } from "./types";
|
||||
@@ -67,6 +68,7 @@ export function SimilarImageMasonry({
|
||||
onLoadMore: () => void;
|
||||
onOpen: (image: GalleryImage) => void;
|
||||
}) {
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const safeColumnCount = Math.max(1, Math.floor(columnCount));
|
||||
const columns = Array.from({ length: safeColumnCount }, () => [] as GalleryImage[]);
|
||||
const heights = Array.from({ length: safeColumnCount }, () => 0);
|
||||
@@ -76,6 +78,23 @@ export function SimilarImageMasonry({
|
||||
heights[target] += image.height / Math.max(1, image.width) + 0.12;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = loadMoreSentinelRef.current;
|
||||
if (!sentinel || !hasMore || loadingMore || error) return;
|
||||
const scrollRoot = sentinel.closest<HTMLElement>(".image-viewer");
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) onLoadMore();
|
||||
},
|
||||
{
|
||||
root: scrollRoot,
|
||||
rootMargin: "800px 0px",
|
||||
},
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [error, hasMore, images.length, loadingMore, onLoadMore]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -98,19 +117,17 @@ export function SimilarImageMasonry({
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div className="viewer-similar-pagination">
|
||||
<div
|
||||
ref={loadMoreSentinelRef}
|
||||
className="viewer-similar-pagination"
|
||||
data-state={hasMore ? (loadingMore ? "loading" : "ready") : "complete"}
|
||||
aria-live="polite"
|
||||
>
|
||||
{hasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-similar-load-more"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
aria-busy={loadingMore}
|
||||
>
|
||||
<span className="viewer-similar-auto-load" aria-busy={loadingMore}>
|
||||
{loadingMore && <LoaderCircle className="spin" size={15} />}
|
||||
<span>{loadingMore ? "正在加载" : "加载更多相似图片"}</span>
|
||||
{!loadingMore && <small>{images.length} / {total}</small>}
|
||||
</button>
|
||||
<span>{loadingMore ? "正在续载相似图片" : "继续下滑自动加载"}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="viewer-similar-count">
|
||||
已显示全部 {total.toLocaleString()} 张相似图片
|
||||
@@ -122,10 +139,17 @@ export function SimilarImageMasonry({
|
||||
);
|
||||
}
|
||||
|
||||
export function SimilarImageSkeleton() {
|
||||
export function SimilarImageSkeleton({ columnCount }: { columnCount: number }) {
|
||||
const safeColumnCount = Math.max(1, Math.floor(columnCount));
|
||||
const itemCount = Math.max(4, safeColumnCount * 2);
|
||||
return (
|
||||
<div className="viewer-similar-skeleton" aria-label="正在查找相似图片">
|
||||
{[1, 2, 3, 4, 5, 6].map((item) => <span key={item} />)}
|
||||
<div
|
||||
className="viewer-similar-skeleton"
|
||||
aria-label="正在查找相似图片"
|
||||
data-columns={safeColumnCount}
|
||||
style={{ "--similar-columns": safeColumnCount } as CSSProperties}
|
||||
>
|
||||
{Array.from({ length: itemCount }, (_, index) => <span key={index} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export function ToolbarPopover({
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
data-state={open ? "open" : "closed"}
|
||||
title={triggerLabel}
|
||||
>
|
||||
<span className="toolbar-popover-trigger-icon" aria-hidden="true">
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
GalleryImage,
|
||||
GallerySummary,
|
||||
ImagesPage,
|
||||
PhotoDetails,
|
||||
SortMode,
|
||||
ThumbnailStatus,
|
||||
} from "./types";
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
const IMAGE_SEARCH_REQUEST_TIMEOUT_MS = 60_000;
|
||||
const SIMILAR_REQUEST_TIMEOUT_MS = 60_000;
|
||||
const PHOTO_DETAILS_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type JsonValidator<T> = (value: unknown) => value is T;
|
||||
@@ -60,6 +62,27 @@ function isImagesPage(value: unknown): value is ImagesPage {
|
||||
&& (value.nextOffset === null || isNonNegativeInteger(value.nextOffset));
|
||||
}
|
||||
|
||||
function isHistogramChannel(value: unknown): value is number[] {
|
||||
return Array.isArray(value)
|
||||
&& value.length === 256
|
||||
&& value.every(isNonNegativeInteger);
|
||||
}
|
||||
|
||||
function isPhotoDetails(value: unknown): value is PhotoDetails {
|
||||
return isObject(value)
|
||||
&& isNonNegativeInteger(value.fileSize)
|
||||
&& isNonNegativeInteger(value.modifiedMs)
|
||||
&& Array.isArray(value.exif)
|
||||
&& value.exif.every((field) => isObject(field)
|
||||
&& typeof field.label === "string"
|
||||
&& typeof field.value === "string")
|
||||
&& isObject(value.histogram)
|
||||
&& isHistogramChannel(value.histogram.red)
|
||||
&& isHistogramChannel(value.histogram.green)
|
||||
&& isHistogramChannel(value.histogram.blue)
|
||||
&& isHistogramChannel(value.histogram.luminance);
|
||||
}
|
||||
|
||||
function isThumbnailStatus(value: unknown): value is ThumbnailStatus {
|
||||
return isObject(value)
|
||||
&& isNonNegativeInteger(value.total)
|
||||
@@ -203,3 +226,15 @@ export function getSimilarImages(
|
||||
SIMILAR_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPhotoDetails(
|
||||
imageId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PhotoDetails> {
|
||||
return getJson(
|
||||
`/api/images/${encodeURIComponent(imageId)}/details`,
|
||||
isPhotoDetails,
|
||||
signal,
|
||||
PHOTO_DETAILS_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
const COUNT_FORMATTER = new Intl.NumberFormat("zh-CN");
|
||||
const FILE_SIZE_UNITS = ["KB", "MB", "GB", "TB"] as const;
|
||||
|
||||
export function formatCount(value: number): string {
|
||||
return COUNT_FORMATTER.format(value);
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${COUNT_FORMATTER.format(bytes)} B`;
|
||||
let value = bytes / 1024;
|
||||
let unit: string = FILE_SIZE_UNITS[0];
|
||||
for (let index = 1; index < FILE_SIZE_UNITS.length && value >= 1024; index += 1) {
|
||||
value /= 1024;
|
||||
unit = FILE_SIZE_UNITS[index];
|
||||
}
|
||||
const maximumFractionDigits = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return `${value.toLocaleString(undefined, { maximumFractionDigits })} ${unit}`;
|
||||
}
|
||||
|
||||
export function formatPixelCount(megapixels: number): string {
|
||||
return megapixels >= 1
|
||||
? `${megapixels.toFixed(1)} MP`
|
||||
: `${Math.round(megapixels * 1000)} KP`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render } from "preact";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
import "./PhotoInformation.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Missing #root application container");
|
||||
|
||||
+155
-265
@@ -237,6 +237,16 @@ select:focus-visible,
|
||||
background: rgba(231, 241, 238, .74);
|
||||
}
|
||||
|
||||
.gallery-sidebar-toggle[aria-expanded="true"],
|
||||
.gallery-sidebar-toggle[aria-expanded="true"]:hover,
|
||||
.gallery-sidebar-toggle[aria-expanded="true"]:focus-visible {
|
||||
width: calc(var(--sidebar-width) - 20px);
|
||||
justify-items: center;
|
||||
padding: 0;
|
||||
color: #216b63;
|
||||
background: rgba(231, 241, 238, .74);
|
||||
}
|
||||
|
||||
.sidebar-toggle-button {
|
||||
color: #59615e;
|
||||
}
|
||||
@@ -525,21 +535,31 @@ select:focus-visible,
|
||||
transition: transform var(--motion-medium) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.toolbar-popover-trigger:hover,
|
||||
.toolbar-popover.is-open .toolbar-popover-trigger,
|
||||
.toolbar-action-button:hover {
|
||||
.toolbar-popover-trigger[aria-expanded="true"] {
|
||||
color: #0f6d64;
|
||||
background: #eaf2f0;
|
||||
border-color: #c5ddd8;
|
||||
}
|
||||
|
||||
.explore-toggle.is-active {
|
||||
.explore-toggle.is-active,
|
||||
.explore-toggle[aria-pressed="true"],
|
||||
.explore-toggle[aria-pressed="true"]:hover,
|
||||
.explore-toggle[aria-pressed="true"]:focus-visible {
|
||||
color: #0f6d64;
|
||||
background: #e8f2f0;
|
||||
border-color: #bfdad5;
|
||||
box-shadow: 0 3px 10px rgba(30, 100, 91, .1);
|
||||
}
|
||||
|
||||
.toolbar-popover-trigger[aria-expanded="true"],
|
||||
.toolbar-popover-trigger[aria-expanded="true"]:hover,
|
||||
.toolbar-popover-trigger[aria-expanded="true"]:focus-visible {
|
||||
color: #0f6d64;
|
||||
background: #eaf2f0;
|
||||
border-color: #c5ddd8;
|
||||
}
|
||||
|
||||
.toolbar-state-dot {
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
@@ -564,6 +584,7 @@ select:focus-visible,
|
||||
}
|
||||
|
||||
.explore-toggle.is-active .toolbar-state-dot,
|
||||
.explore-toggle[aria-pressed="true"] .toolbar-state-dot,
|
||||
.topbar-search.has-query .toolbar-state-dot {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@@ -1059,7 +1080,8 @@ select:focus-visible,
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.viewer-header .viewer-control {
|
||||
.viewer-header .viewer-control,
|
||||
.viewer-details-toolbar .viewer-control {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
@@ -1067,8 +1089,8 @@ select:focus-visible,
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.viewer-header .viewer-control:hover,
|
||||
.viewer-header .viewer-control:focus-visible {
|
||||
.viewer-header .viewer-control:focus-visible,
|
||||
.viewer-details-toolbar .viewer-control:focus-visible {
|
||||
background: rgba(255, 255, 255, .105);
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -1106,9 +1128,6 @@ select:focus-visible,
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.viewer-control:hover,
|
||||
.viewer-nav:hover:not(:disabled),
|
||||
.viewer-scroll-cue:hover,
|
||||
.viewer-control:focus-visible,
|
||||
.viewer-nav:focus-visible,
|
||||
.viewer-scroll-cue:focus-visible {
|
||||
@@ -1239,14 +1258,26 @@ select:focus-visible,
|
||||
top: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: .64;
|
||||
color: rgba(249, 252, 251, .88);
|
||||
background: linear-gradient(145deg, rgba(70, 88, 82, .42), rgba(23, 29, 27, .34));
|
||||
border: 0;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, .24);
|
||||
backdrop-filter: blur(20px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.35);
|
||||
opacity: .78;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.viewer-nav:hover:not(:disabled),
|
||||
.viewer-nav:focus-visible { opacity: 1; }
|
||||
.viewer-nav:focus-visible {
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, rgba(89, 112, 104, .52), rgba(31, 40, 37, .43));
|
||||
border: 0;
|
||||
box-shadow: 0 13px 34px rgba(0, 0, 0, .3);
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1.035);
|
||||
}
|
||||
|
||||
.viewer-nav:active:not(:disabled) { transform: translateY(-50%) scale(.93); }
|
||||
.viewer-nav:active:not(:disabled) { transform: translateY(-50%) scale(.95); }
|
||||
|
||||
.viewer-nav:disabled {
|
||||
opacity: 0;
|
||||
@@ -1274,15 +1305,13 @@ select:focus-visible,
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
padding: 0;
|
||||
color: rgba(248, 250, 249, .76);
|
||||
background: rgba(18, 22, 20, .6);
|
||||
border: 1px solid rgba(255, 255, 255, .13);
|
||||
color: rgba(249, 252, 251, .86);
|
||||
background: linear-gradient(145deg, rgba(70, 88, 82, .44), rgba(23, 29, 27, .36));
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 10px 32px rgba(0, 0, 0, .3),
|
||||
inset 0 1px rgba(255, 255, 255, .04);
|
||||
backdrop-filter: blur(16px) saturate(1.12);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.12);
|
||||
box-shadow: 0 12px 34px rgba(0, 0, 0, .28);
|
||||
backdrop-filter: blur(22px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.4);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
@@ -1291,13 +1320,17 @@ select:focus-visible,
|
||||
transform 150ms var(--ease-standard);
|
||||
}
|
||||
|
||||
.viewer-scroll-cue:hover,
|
||||
.viewer-scroll-cue:focus-visible {
|
||||
color: #fff;
|
||||
background: rgba(48, 55, 52, .8);
|
||||
border-color: rgba(255, 255, 255, .23);
|
||||
outline: none;
|
||||
transform: translateY(2px);
|
||||
background: linear-gradient(145deg, rgba(91, 115, 106, .54), rgba(31, 41, 37, .45));
|
||||
border: 0;
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, .34);
|
||||
transform: translateY(2px) scale(1.035);
|
||||
}
|
||||
|
||||
.viewer-scroll-cue:focus-visible {
|
||||
outline: 2px solid #8fd2c9;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.viewer-scroll-cue:active {
|
||||
@@ -1305,12 +1338,12 @@ select:focus-visible,
|
||||
}
|
||||
|
||||
.viewer-scroll-cue svg {
|
||||
filter: drop-shadow(0 2px 5px rgba(0, 0, 0, .64));
|
||||
filter: drop-shadow(0 2px 5px rgba(0, 0, 0, .48));
|
||||
animation: viewer-details-chevron 1.8s var(--ease-standard) infinite;
|
||||
}
|
||||
|
||||
.viewer-details-return:focus-visible,
|
||||
.viewer-details-download:focus-visible {
|
||||
.viewer-details-toolbar .viewer-control:focus-visible {
|
||||
outline: 2px solid #8fd2c9;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@@ -1334,7 +1367,7 @@ select:focus-visible,
|
||||
}
|
||||
|
||||
.viewer-details-inner {
|
||||
width: min(960px, calc(100% - 40px));
|
||||
width: min(1640px, calc(100% - 48px));
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
margin: 0 auto;
|
||||
@@ -1345,12 +1378,12 @@ select:focus-visible,
|
||||
position: sticky;
|
||||
z-index: 3;
|
||||
top: 0;
|
||||
min-height: 74px;
|
||||
min-height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 13px 0;
|
||||
padding: 8px 0;
|
||||
background: linear-gradient(to bottom, rgba(18, 21, 20, .98), rgba(18, 21, 20, .9));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, .08);
|
||||
backdrop-filter: blur(16px) saturate(1.08);
|
||||
@@ -1367,7 +1400,7 @@ select:focus-visible,
|
||||
.viewer-details-heading h2 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 680;
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
@@ -1378,160 +1411,17 @@ select:focus-visible,
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.viewer-details-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.viewer-details-toolbar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.viewer-details-content {
|
||||
width: min(840px, 100%);
|
||||
width: min(1520px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
gap: 18px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(30px, 5vh, 54px) 0 max(112px, calc(84px + env(safe-area-inset-bottom)));
|
||||
}
|
||||
|
||||
.viewer-details-summary {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, .045);
|
||||
border: 1px solid rgba(255, 255, 255, .075);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 54px rgba(0, 0, 0, .13);
|
||||
}
|
||||
|
||||
.viewer-details-summary img {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
background: #0b0d0c;
|
||||
border: 1px solid rgba(255, 255, 255, .08);
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.viewer-details-summary > div {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.viewer-details-kind {
|
||||
color: rgba(143, 210, 201, .72);
|
||||
font-size: 10px;
|
||||
font-weight: 650;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.viewer-details-summary h3 {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
font-size: clamp(16px, 2.4vw, 21px);
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-details-summary p {
|
||||
margin: 0;
|
||||
color: rgba(239, 243, 241, .5);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.viewer-details-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.viewer-details-section > h3 {
|
||||
margin: 0 2px;
|
||||
color: rgba(248, 250, 249, .82);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.viewer-details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, .07);
|
||||
border: 1px solid rgba(255, 255, 255, .07);
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.viewer-details-grid > div {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 15px;
|
||||
background: rgba(29, 34, 32, .98);
|
||||
}
|
||||
|
||||
.viewer-details-grid dt {
|
||||
color: rgba(239, 243, 241, .45);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.viewer-details-grid dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: rgba(250, 251, 250, .9);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-details-download {
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 11px 16px;
|
||||
color: rgba(246, 248, 247, .84);
|
||||
background: rgba(255, 255, 255, .045);
|
||||
border: 1px solid rgba(255, 255, 255, .075);
|
||||
border-radius: 15px;
|
||||
text-decoration: none;
|
||||
transition: color var(--motion-fast) ease, background var(--motion-fast) ease;
|
||||
}
|
||||
|
||||
.viewer-details-download:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, .085);
|
||||
}
|
||||
|
||||
.viewer-details-download > span {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.viewer-details-download strong { font-size: 12px; }
|
||||
|
||||
.viewer-details-download small {
|
||||
color: rgba(239, 243, 241, .46);
|
||||
font-size: 10px;
|
||||
padding: 22px 0 max(80px, calc(64px + env(safe-area-inset-bottom)));
|
||||
}
|
||||
|
||||
.viewer-similar-trigger.is-active {
|
||||
@@ -1540,14 +1430,32 @@ select:focus-visible,
|
||||
border-color: rgba(143, 210, 201, .38);
|
||||
}
|
||||
|
||||
.viewer-header .viewer-control[aria-pressed="true"],
|
||||
.viewer-details-toolbar .viewer-control[aria-pressed="true"],
|
||||
.viewer-header .viewer-control[aria-pressed="true"]:hover,
|
||||
.viewer-header .viewer-control[aria-pressed="true"]:focus-visible,
|
||||
.viewer-details-toolbar .viewer-control[aria-pressed="true"]:hover,
|
||||
.viewer-details-toolbar .viewer-control[aria-pressed="true"]:focus-visible {
|
||||
color: #b8eee7;
|
||||
background: rgba(40, 122, 114, .48);
|
||||
border-color: rgba(143, 210, 201, .38);
|
||||
}
|
||||
|
||||
.viewer-similar-section {
|
||||
scroll-margin-top: 90px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.viewer-similar-section.is-idle {
|
||||
height: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.viewer-similar-heading {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -1561,68 +1469,23 @@ select:focus-visible,
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.viewer-similar-heading h3,
|
||||
.viewer-similar-heading p {
|
||||
margin: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.viewer-similar-heading h3 {
|
||||
margin: 0;
|
||||
color: rgba(250, 252, 251, .94);
|
||||
font-size: 14px;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.viewer-similar-heading p {
|
||||
color: rgba(239, 243, 241, .46);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.viewer-similar-heading > svg {
|
||||
flex: 0 0 auto;
|
||||
color: rgba(143, 210, 201, .64);
|
||||
}
|
||||
|
||||
.viewer-similar-search-button,
|
||||
.viewer-similar-load-more {
|
||||
.viewer-similar-auto-load {
|
||||
color: rgba(247, 250, 249, .86);
|
||||
background: rgba(255, 255, 255, .05);
|
||||
border: 1px solid rgba(255, 255, 255, .085);
|
||||
transition:
|
||||
color var(--motion-fast) ease,
|
||||
background var(--motion-fast) ease,
|
||||
border-color var(--motion-fast) ease,
|
||||
transform var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.viewer-similar-search-button {
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 11px 16px;
|
||||
text-align: left;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.viewer-similar-search-button:hover,
|
||||
.viewer-similar-load-more:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: rgba(74, 135, 126, .16);
|
||||
border-color: rgba(143, 210, 201, .2);
|
||||
}
|
||||
|
||||
.viewer-similar-search-button:active,
|
||||
.viewer-similar-load-more:active:not(:disabled) {
|
||||
transform: scale(.988);
|
||||
}
|
||||
|
||||
.viewer-similar-search-button > span {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.viewer-similar-masonry {
|
||||
@@ -1721,7 +1584,7 @@ select:focus-visible,
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.viewer-similar-load-more {
|
||||
.viewer-similar-auto-load {
|
||||
min-width: 210px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
@@ -1729,23 +1592,16 @@ select:focus-visible,
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 9px 15px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.viewer-similar-load-more:disabled {
|
||||
opacity: .56;
|
||||
cursor: wait;
|
||||
.viewer-similar-pagination[data-state="ready"] .viewer-similar-auto-load {
|
||||
color: rgba(239, 243, 241, .36);
|
||||
}
|
||||
|
||||
.viewer-similar-load-more small {
|
||||
padding-left: 8px;
|
||||
color: rgba(239, 243, 241, .42);
|
||||
border-left: 1px solid rgba(255, 255, 255, .12);
|
||||
font-size: 9px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
.viewer-similar-pagination[data-state="loading"] .viewer-similar-auto-load {
|
||||
color: rgba(184, 238, 231, .72);
|
||||
}
|
||||
|
||||
.viewer-similar-count {
|
||||
@@ -1755,7 +1611,7 @@ select:focus-visible,
|
||||
|
||||
.viewer-similar-skeleton {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(var(--similar-columns, 3), minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -1884,6 +1740,44 @@ select:focus-visible,
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.toolbar-popover-trigger:hover,
|
||||
.toolbar-action-button:hover {
|
||||
color: #0f6d64;
|
||||
background: #eaf2f0;
|
||||
border-color: #c5ddd8;
|
||||
}
|
||||
|
||||
.viewer-header .viewer-control:hover,
|
||||
.viewer-details-toolbar .viewer-control:hover {
|
||||
background: rgba(255, 255, 255, .105);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.viewer-control:hover,
|
||||
.viewer-nav:hover:not(:disabled),
|
||||
.viewer-scroll-cue:hover {
|
||||
color: #fff;
|
||||
background: rgba(48, 55, 52, .76);
|
||||
border-color: rgba(255, 255, 255, .22);
|
||||
}
|
||||
|
||||
.viewer-nav:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, rgba(89, 112, 104, .52), rgba(31, 40, 37, .43));
|
||||
border: 0;
|
||||
box-shadow: 0 13px 34px rgba(0, 0, 0, .3);
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1.035);
|
||||
}
|
||||
|
||||
.viewer-scroll-cue:hover {
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, rgba(91, 115, 106, .54), rgba(31, 41, 37, .45));
|
||||
border: 0;
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, .34);
|
||||
transform: translateY(2px) scale(1.035);
|
||||
}
|
||||
|
||||
.brand:hover .brand-mark {
|
||||
background: #216e66;
|
||||
box-shadow: 0 5px 13px rgba(32, 100, 92, .2);
|
||||
@@ -2028,29 +1922,14 @@ select:focus-visible,
|
||||
}
|
||||
.viewer-details-inner { width: calc(100% - 24px); }
|
||||
.viewer-details-header {
|
||||
min-height: 66px;
|
||||
padding: 10px 0;
|
||||
min-height: 58px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.viewer-details-content {
|
||||
gap: 22px;
|
||||
padding: 24px 0 max(88px, calc(64px + env(safe-area-inset-bottom)));
|
||||
gap: 16px;
|
||||
padding: 16px 0 max(72px, calc(56px + env(safe-area-inset-bottom)));
|
||||
}
|
||||
.viewer-details-summary {
|
||||
grid-template-columns: 80px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.viewer-details-summary img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.viewer-details-summary h3 { font-size: 15px; }
|
||||
.viewer-details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.viewer-details-grid > div { padding: 13px 14px; }
|
||||
.viewer-similar-section { scroll-margin-top: 76px; }
|
||||
.viewer-similar-heading p { max-width: 240px; }
|
||||
.viewer-similar-section { scroll-margin-top: 68px; }
|
||||
.viewer-similar-masonry {
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -2060,11 +1939,9 @@ select:focus-visible,
|
||||
}
|
||||
.viewer-similar-card figcaption { padding: 8px; font-size: 9px; }
|
||||
.viewer-similar-skeleton {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.viewer-similar-skeleton span:nth-child(n + 5) { display: none; }
|
||||
.viewer-similar-load-more { width: 100%; }
|
||||
.viewer-similar-auto-load { width: 100%; }
|
||||
.viewer-load-error {
|
||||
bottom: max(64px, calc(52px + env(safe-area-inset-bottom)));
|
||||
}
|
||||
@@ -2124,6 +2001,19 @@ select:focus-visible,
|
||||
.viewer-load-error { bottom: max(58px, calc(48px + env(safe-area-inset-bottom))); }
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.viewer-details-header { gap: 10px; }
|
||||
.viewer-details-heading span { display: none; }
|
||||
.viewer-details-toolbar {
|
||||
gap: 1px;
|
||||
padding: 3px;
|
||||
}
|
||||
.viewer-details-toolbar .viewer-control {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes viewer-media-swipe-in-next {
|
||||
from { opacity: .24; transform: translate3d(min(22vw, 96px), 0, 0) scale(.985); }
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
|
||||
|
||||
@@ -25,6 +25,25 @@ export interface ImagesPage {
|
||||
nextOffset: number | null;
|
||||
}
|
||||
|
||||
export interface PhotoExifField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface PhotoHistogram {
|
||||
red: number[];
|
||||
green: number[];
|
||||
blue: number[];
|
||||
luminance: number[];
|
||||
}
|
||||
|
||||
export interface PhotoDetails {
|
||||
fileSize: number;
|
||||
modifiedMs: number;
|
||||
exif: PhotoExifField[];
|
||||
histogram: PhotoHistogram;
|
||||
}
|
||||
|
||||
export interface ThumbnailStatus {
|
||||
total: number;
|
||||
ready: number;
|
||||
|
||||
+197
-13
@@ -1,4 +1,10 @@
|
||||
use std::{env, ffi::OsString, net::SocketAddr, path::PathBuf, time::Duration};
|
||||
use std::{
|
||||
env,
|
||||
ffi::{OsStr, OsString},
|
||||
net::SocketAddr,
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
|
||||
@@ -6,7 +12,7 @@ use anyhow::{Context, Result, anyhow, bail};
|
||||
pub struct Config {
|
||||
pub gallery_dir: PathBuf,
|
||||
pub cache_dir: PathBuf,
|
||||
pub text_search: Option<TextSearchFiles>,
|
||||
pub text_search: Option<TextSearchSource>,
|
||||
pub listen: SocketAddr,
|
||||
pub initial_batch: usize,
|
||||
pub workers: usize,
|
||||
@@ -19,6 +25,26 @@ pub struct TextSearchFiles {
|
||||
pub vocabulary: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TextSearchSource {
|
||||
Local(TextSearchFiles),
|
||||
AutoDownload(TextSearchFiles),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum TextSearchModelInput {
|
||||
Local(PathBuf),
|
||||
AutoDownload,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
const TEXT_SEARCH_MODEL_FILENAME: &str = "model.safetensors";
|
||||
const TEXT_SEARCH_VOCABULARY_FILENAME: &str = "vocab.txt";
|
||||
const TEXT_SEARCH_ENABLED_VALUE: &str = "true";
|
||||
const TEXT_SEARCH_DISABLED_VALUE: &str = "false";
|
||||
const TEXT_SEARCH_LEGACY_AUTO_VALUE: &str = "auto";
|
||||
const TEXT_SEARCH_AUTO_CACHE_DIRECTORY: &str = "models/chinese-clip-vit-base-patch16-f4a64596";
|
||||
|
||||
impl Config {
|
||||
pub fn from_env_and_args() -> Result<Self> {
|
||||
let cwd = env::current_dir().context("cannot determine the working directory")?;
|
||||
@@ -46,7 +72,7 @@ impl Config {
|
||||
let mut text_search_model = if is_overridden("--text-search-model") {
|
||||
None
|
||||
} else {
|
||||
env_path(&get_env, "PIXHELF_TEXT_SEARCH_MODEL", &cwd)?
|
||||
env_text_search_model(&get_env, &cwd)?.or(Some(TextSearchModelInput::AutoDownload))
|
||||
};
|
||||
let mut text_search_vocabulary = if is_overridden("--text-search-vocab") {
|
||||
None
|
||||
@@ -85,7 +111,7 @@ impl Config {
|
||||
"--gallery-dir" => gallery_dir = path_value(&mut args, &arg, &cwd)?,
|
||||
"--cache-dir" => cache_dir = path_value(&mut args, &arg, &cwd)?,
|
||||
"--text-search-model" => {
|
||||
text_search_model = Some(path_value(&mut args, &arg, &cwd)?);
|
||||
text_search_model = Some(text_search_model_value(&mut args, &arg, &cwd)?);
|
||||
}
|
||||
"--text-search-vocab" => {
|
||||
text_search_vocabulary = Some(path_value(&mut args, &arg, &cwd)?);
|
||||
@@ -117,13 +143,23 @@ impl Config {
|
||||
cache_dir = canonical_directory(&cache_dir, "cache")?;
|
||||
ensure_cache_is_external(&gallery_dir, &cache_dir)?;
|
||||
let text_search = match text_search_model {
|
||||
Some(model) => {
|
||||
let model = canonical_file(&model, "text-search model")?;
|
||||
let vocabulary = text_search_vocabulary
|
||||
.unwrap_or_else(|| model.parent().unwrap_or(&cwd).join("vocab.txt"));
|
||||
let vocabulary = canonical_file(&vocabulary, "text-search vocabulary")?;
|
||||
Some(TextSearchFiles { model, vocabulary })
|
||||
Some(TextSearchModelInput::Local(model)) => Some(TextSearchSource::Local(
|
||||
resolve_text_search_files(&model, text_search_vocabulary.as_deref(), &cwd)?,
|
||||
)),
|
||||
Some(TextSearchModelInput::AutoDownload) if text_search_vocabulary.is_some() => {
|
||||
bail!("text-search vocabulary cannot be used with automatic model download");
|
||||
}
|
||||
Some(TextSearchModelInput::AutoDownload) => {
|
||||
let directory = cache_dir.join(TEXT_SEARCH_AUTO_CACHE_DIRECTORY);
|
||||
Some(TextSearchSource::AutoDownload(TextSearchFiles {
|
||||
model: directory.join(TEXT_SEARCH_MODEL_FILENAME),
|
||||
vocabulary: directory.join(TEXT_SEARCH_VOCABULARY_FILENAME),
|
||||
}))
|
||||
}
|
||||
Some(TextSearchModelInput::Disabled) if text_search_vocabulary.is_some() => {
|
||||
bail!("text-search vocabulary requires text search to be enabled");
|
||||
}
|
||||
Some(TextSearchModelInput::Disabled) => None,
|
||||
None if text_search_vocabulary.is_some() => {
|
||||
bail!("--text-search-vocab requires --text-search-model");
|
||||
}
|
||||
@@ -158,6 +194,31 @@ fn canonical_file(path: &std::path::Path, label: &str) -> Result<PathBuf> {
|
||||
.with_context(|| format!("cannot resolve {label} file: {}", path.display()))
|
||||
}
|
||||
|
||||
fn resolve_text_search_files(
|
||||
model_or_directory: &std::path::Path,
|
||||
vocabulary: Option<&std::path::Path>,
|
||||
cwd: &std::path::Path,
|
||||
) -> Result<TextSearchFiles> {
|
||||
let model = if model_or_directory.is_dir() {
|
||||
model_or_directory.join(TEXT_SEARCH_MODEL_FILENAME)
|
||||
} else {
|
||||
model_or_directory.to_path_buf()
|
||||
};
|
||||
let model = canonical_file(&model, "text-search model")?;
|
||||
let vocabulary = vocabulary.map_or_else(
|
||||
|| {
|
||||
model
|
||||
.parent()
|
||||
.unwrap_or(cwd)
|
||||
.join(TEXT_SEARCH_VOCABULARY_FILENAME)
|
||||
},
|
||||
std::path::Path::to_path_buf,
|
||||
);
|
||||
let vocabulary = canonical_file(&vocabulary, "text-search vocabulary")?;
|
||||
|
||||
Ok(TextSearchFiles { model, vocabulary })
|
||||
}
|
||||
|
||||
fn ensure_cache_is_external(
|
||||
gallery_dir: &std::path::Path,
|
||||
cache_dir: &std::path::Path,
|
||||
@@ -204,6 +265,14 @@ fn env_path(
|
||||
Ok(env_value(get_env, name)?.map(|value| resolve_path(PathBuf::from(value), cwd)))
|
||||
}
|
||||
|
||||
fn env_text_search_model(
|
||||
get_env: &impl Fn(&str) -> Option<OsString>,
|
||||
cwd: &std::path::Path,
|
||||
) -> Result<Option<TextSearchModelInput>> {
|
||||
Ok(env_value(get_env, "PIXHELF_TEXT_SEARCH_MODEL")?
|
||||
.map(|value| parse_text_search_model(value, cwd)))
|
||||
}
|
||||
|
||||
fn env_number(
|
||||
get_env: &impl Fn(&str) -> Option<OsString>,
|
||||
name: &str,
|
||||
@@ -223,6 +292,29 @@ fn path_value(
|
||||
Ok(resolve_path(PathBuf::from(value(args, flag)?), cwd))
|
||||
}
|
||||
|
||||
fn text_search_model_value(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
cwd: &std::path::Path,
|
||||
) -> Result<TextSearchModelInput> {
|
||||
Ok(parse_text_search_model(
|
||||
OsString::from(value(args, flag)?),
|
||||
cwd,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_text_search_model(value: OsString, cwd: &std::path::Path) -> TextSearchModelInput {
|
||||
if value == OsStr::new(TEXT_SEARCH_ENABLED_VALUE)
|
||||
|| value == OsStr::new(TEXT_SEARCH_LEGACY_AUTO_VALUE)
|
||||
{
|
||||
TextSearchModelInput::AutoDownload
|
||||
} else if value == OsStr::new(TEXT_SEARCH_DISABLED_VALUE) {
|
||||
TextSearchModelInput::Disabled
|
||||
} else {
|
||||
TextSearchModelInput::Local(resolve_path(PathBuf::from(value), cwd))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(path: PathBuf, cwd: &std::path::Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path
|
||||
@@ -264,8 +356,8 @@ Usage: pixhelf [OPTIONS]\n\n\
|
||||
Options:\n \
|
||||
--gallery-dir PATH Gallery root (default: ./pic)\n \
|
||||
--cache-dir PATH Thumbnail cache (default: ./.pixhelf-cache/thumbnails)\n \
|
||||
--text-search-model PATH Optional OFA Chinese-CLIP ViT-B/16 safetensors model\n \
|
||||
--text-search-vocab PATH Chinese-CLIP vocab.txt (default: beside model)\n \
|
||||
--text-search-model VALUE 'true', 'false', Chinese-CLIP directory, or model file (default: true)\n \
|
||||
--text-search-vocab PATH Optional vocab.txt override (default: beside model)\n \
|
||||
--listen HOST:PORT Listen address (default: 0.0.0.0:3002)\n \
|
||||
--initial-batch N Priority thumbnail batch (default: 60)\n \
|
||||
--workers N Background thumbnail workers (default: 2-4, based on CPU)\n \
|
||||
@@ -349,7 +441,7 @@ mod tests {
|
||||
#[test]
|
||||
fn resolves_only_existing_model_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let model = temp.path().join("model.safetensors");
|
||||
let model = temp.path().join(TEXT_SEARCH_MODEL_FILENAME);
|
||||
std::fs::write(&model, b"model").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
@@ -360,6 +452,98 @@ mod tests {
|
||||
assert!(canonical_file(&temp.path().join("missing"), "model").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_text_search_directory_and_legacy_file_path() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let directory = temp.path().join("chinese-clip");
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let model = directory.join(TEXT_SEARCH_MODEL_FILENAME);
|
||||
let vocabulary = directory.join(TEXT_SEARCH_VOCABULARY_FILENAME);
|
||||
std::fs::write(&model, b"model").unwrap();
|
||||
std::fs::write(&vocabulary, b"vocabulary").unwrap();
|
||||
|
||||
let from_directory = resolve_text_search_files(&directory, None, temp.path()).unwrap();
|
||||
let from_file = resolve_text_search_files(&model, None, temp.path()).unwrap();
|
||||
|
||||
assert_eq!(from_directory.model, model.canonicalize().unwrap());
|
||||
assert_eq!(
|
||||
from_directory.vocabulary,
|
||||
vocabulary.canonicalize().unwrap()
|
||||
);
|
||||
assert_eq!(from_file.model, from_directory.model);
|
||||
assert_eq!(from_file.vocabulary, from_directory.vocabulary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_search_is_enabled_by_default_and_uses_the_persistent_cache() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
let cache = temp.path().join("cache");
|
||||
std::fs::create_dir(&gallery).unwrap();
|
||||
let environment = HashMap::from([
|
||||
("PIXHELF_GALLERY_DIR", gallery.as_os_str().to_os_string()),
|
||||
("PIXHELF_CACHE_DIR", cache.as_os_str().to_os_string()),
|
||||
]);
|
||||
|
||||
let config = Config::from_sources(temp.path().to_path_buf(), [], |name| {
|
||||
environment.get(name).cloned()
|
||||
})
|
||||
.unwrap();
|
||||
let expected = config.cache_dir.join(TEXT_SEARCH_AUTO_CACHE_DIRECTORY);
|
||||
|
||||
let Some(TextSearchSource::AutoDownload(files)) = config.text_search else {
|
||||
panic!("expected automatic model download");
|
||||
};
|
||||
assert_eq!(files.model, expected.join(TEXT_SEARCH_MODEL_FILENAME));
|
||||
assert_eq!(
|
||||
files.vocabulary,
|
||||
expected.join(TEXT_SEARCH_VOCABULARY_FILENAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_model_download_rejects_a_vocabulary_override() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(temp.path().join("pic")).unwrap();
|
||||
let args = [
|
||||
"--text-search-model",
|
||||
"true",
|
||||
"--text-search-vocab",
|
||||
"vocab.txt",
|
||||
]
|
||||
.map(str::to_owned);
|
||||
|
||||
let error = Config::from_sources(temp.path().to_path_buf(), args, |_| None).unwrap_err();
|
||||
assert!(error.to_string().contains("automatic model download"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_false_disables_text_search_and_auto_remains_compatible() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
assert!(matches!(
|
||||
parse_text_search_model(OsString::from("false"), temp.path()),
|
||||
TextSearchModelInput::Disabled
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_text_search_model(OsString::from("auto"), temp.path()),
|
||||
TextSearchModelInput::AutoDownload
|
||||
));
|
||||
|
||||
let gallery = temp.path().join("gallery");
|
||||
let cache = temp.path().join("cache");
|
||||
std::fs::create_dir(&gallery).unwrap();
|
||||
let environment = HashMap::from([
|
||||
("PIXHELF_GALLERY_DIR", gallery.as_os_str().to_os_string()),
|
||||
("PIXHELF_CACHE_DIR", cache.as_os_str().to_os_string()),
|
||||
("PIXHELF_TEXT_SEARCH_MODEL", OsString::from("false")),
|
||||
]);
|
||||
let config = Config::from_sources(temp.path().to_path_buf(), [], |name| {
|
||||
environment.get(name).cloned()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(config.text_search.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cache_directories_inside_the_gallery() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
+61
-16
@@ -1,15 +1,16 @@
|
||||
mod config;
|
||||
mod gallery;
|
||||
mod photo_details;
|
||||
mod similarity;
|
||||
mod support;
|
||||
mod text_search;
|
||||
mod thumbs;
|
||||
mod web;
|
||||
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use config::Config;
|
||||
use config::{Config, TextSearchFiles, TextSearchSource};
|
||||
use gallery::{GalleryIndex, scan_gallery};
|
||||
use text_search::TextSearchIndex;
|
||||
use thumbs::ThumbnailManager;
|
||||
@@ -18,6 +19,8 @@ use tracing::{error, info, warn};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use web::AppState;
|
||||
|
||||
const AUTOMATIC_MODEL_RETRY_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -50,20 +53,10 @@ async fn main() -> Result<()> {
|
||||
.map(|record| record.id.clone())
|
||||
.collect();
|
||||
let index = Arc::new(RwLock::new(Arc::new(index)));
|
||||
let text_search = match config.text_search.as_ref() {
|
||||
Some(files) => match TextSearchIndex::load(&files.model, &files.vocabulary) {
|
||||
Ok(index) => Some(index),
|
||||
Err(error) => {
|
||||
warn!(
|
||||
model = %files.model.display(),
|
||||
vocabulary = %files.vocabulary.display(),
|
||||
%error,
|
||||
"natural-language search model unavailable; using filename search"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
let (text_search, automatic_text_search) = match config.text_search.as_ref() {
|
||||
Some(TextSearchSource::Local(files)) => (load_text_search(files), None),
|
||||
Some(TextSearchSource::AutoDownload(files)) => (None, Some(files.clone())),
|
||||
None => (None, None),
|
||||
};
|
||||
let thumbnails = ThumbnailManager::new_with_text_search(
|
||||
config.cache_dir.clone(),
|
||||
@@ -99,6 +92,9 @@ async fn main() -> Result<()> {
|
||||
.await
|
||||
.with_context(|| format!("cannot listen on {}", config.listen))?;
|
||||
info!(listen = %config.listen, "Pixhelf is ready");
|
||||
if let Some(files) = automatic_text_search {
|
||||
start_automatic_text_search(Arc::clone(&thumbnails), files);
|
||||
}
|
||||
|
||||
axum::serve(listener, web::router(state))
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
@@ -106,6 +102,55 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_text_search(files: &TextSearchFiles) -> Option<Arc<TextSearchIndex>> {
|
||||
match TextSearchIndex::load(&files.model, &files.vocabulary) {
|
||||
Ok(index) => Some(index),
|
||||
Err(error) => {
|
||||
warn!(
|
||||
model = %files.model.display(),
|
||||
vocabulary = %files.vocabulary.display(),
|
||||
%error,
|
||||
"natural-language search model unavailable; using filename search"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_automatic_text_search(thumbnails: Arc<ThumbnailManager>, files: TextSearchFiles) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match prepare_automatic_text_search(&files).await {
|
||||
Ok(index) => {
|
||||
if thumbnails.enable_text_search(index) {
|
||||
info!(
|
||||
"background model preparation complete; natural-language search enabled"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
retry_seconds = AUTOMATIC_MODEL_RETRY_INTERVAL.as_secs(),
|
||||
error = %format!("{error:#}"),
|
||||
"natural-language search model is not ready; filename search remains available"
|
||||
);
|
||||
tokio::time::sleep(AUTOMATIC_MODEL_RETRY_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn prepare_automatic_text_search(files: &TextSearchFiles) -> Result<Arc<TextSearchIndex>> {
|
||||
text_search::ensure_automatic_model(files).await?;
|
||||
let model = files.model.clone();
|
||||
let vocabulary = files.vocabulary.clone();
|
||||
tokio::task::spawn_blocking(move || TextSearchIndex::load(&model, &vocabulary))
|
||||
.await
|
||||
.context("natural-language search setup task stopped")?
|
||||
}
|
||||
|
||||
async fn rescan_gallery(state: AppState, config: Config) {
|
||||
loop {
|
||||
tokio::time::sleep(config.scan_interval).await;
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
use std::{fs::File, io::BufReader, path::Path};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use exif::{Exif, In, Tag, Value};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::gallery::ImageRecord;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PhotoDetails {
|
||||
pub(crate) file_size: u64,
|
||||
pub(crate) modified_ms: u64,
|
||||
pub(crate) exif: Vec<ExifField>,
|
||||
pub(crate) histogram: PhotoHistogram,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct ExifField {
|
||||
pub(crate) label: &'static str,
|
||||
pub(crate) value: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct PhotoHistogram {
|
||||
pub(crate) red: Vec<u32>,
|
||||
pub(crate) green: Vec<u32>,
|
||||
pub(crate) blue: Vec<u32>,
|
||||
pub(crate) luminance: Vec<u32>,
|
||||
}
|
||||
|
||||
impl PhotoDetails {
|
||||
pub(crate) fn read(record: &ImageRecord, thumbnail: &Path) -> Result<Self> {
|
||||
record.ensure_source_is_current()?;
|
||||
let exif = read_exif(&record.path)?;
|
||||
let histogram = read_histogram(thumbnail)?;
|
||||
record.ensure_source_is_current()?;
|
||||
Ok(Self {
|
||||
file_size: record.size,
|
||||
modified_ms: record.modified_ms,
|
||||
exif,
|
||||
histogram,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn read_histogram(path: &Path) -> Result<PhotoHistogram> {
|
||||
let image = image::open(path)
|
||||
.with_context(|| format!("cannot decode histogram source {}", path.display()))?
|
||||
.to_rgb8();
|
||||
let mut red = vec![0_u32; 256];
|
||||
let mut green = vec![0_u32; 256];
|
||||
let mut blue = vec![0_u32; 256];
|
||||
let mut luminance = vec![0_u32; 256];
|
||||
|
||||
for pixel in image.pixels() {
|
||||
let [r, g, b] = pixel.0;
|
||||
red[usize::from(r)] += 1;
|
||||
green[usize::from(g)] += 1;
|
||||
blue[usize::from(b)] += 1;
|
||||
// Integer Rec. 709 coefficients, rounded to the nearest 8-bit value.
|
||||
let luma =
|
||||
(54_u16 * u16::from(r) + 183_u16 * u16::from(g) + 19_u16 * u16::from(b) + 128) >> 8;
|
||||
luminance[usize::from(luma)] += 1;
|
||||
}
|
||||
|
||||
Ok(PhotoHistogram {
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
luminance,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_exif(path: &Path) -> Result<Vec<ExifField>> {
|
||||
let file =
|
||||
File::open(path).with_context(|| format!("cannot open EXIF source {}", path.display()))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut parser = exif::Reader::new();
|
||||
parser.continue_on_error(true);
|
||||
let Some(exif) = parser
|
||||
.read_from_container(&mut reader)
|
||||
.or_else(|error| error.distill_partial_result(|_| {}))
|
||||
.ok()
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
Ok(exif_fields(&exif))
|
||||
}
|
||||
|
||||
fn exif_fields(exif: &Exif) -> Vec<ExifField> {
|
||||
let mut fields = Vec::with_capacity(14);
|
||||
|
||||
if let Some(mut captured_at) = ascii_value(exif, Tag::DateTimeOriginal)
|
||||
.or_else(|| ascii_value(exif, Tag::DateTimeDigitized))
|
||||
.or_else(|| ascii_value(exif, Tag::DateTime))
|
||||
{
|
||||
captured_at = normalize_exif_datetime(&captured_at);
|
||||
if let Some(offset) = ascii_value(exif, Tag::OffsetTimeOriginal)
|
||||
.or_else(|| ascii_value(exif, Tag::OffsetTimeDigitized))
|
||||
.or_else(|| ascii_value(exif, Tag::OffsetTime))
|
||||
{
|
||||
captured_at.push(' ');
|
||||
captured_at.push_str(&offset);
|
||||
}
|
||||
push_field(&mut fields, "拍摄时间", Some(captured_at));
|
||||
}
|
||||
|
||||
push_field(
|
||||
&mut fields,
|
||||
"色彩空间",
|
||||
unsigned_value(exif, Tag::ColorSpace).map(|value| match value {
|
||||
1 => "sRGB".to_owned(),
|
||||
0xffff => "未校准".to_owned(),
|
||||
_ => value.to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
let make = ascii_value(exif, Tag::Make);
|
||||
let model = ascii_value(exif, Tag::Model);
|
||||
push_field(&mut fields, "相机", combine_make_and_model(make, model));
|
||||
push_field(&mut fields, "镜头", ascii_value(exif, Tag::LensModel));
|
||||
push_field(
|
||||
&mut fields,
|
||||
"快门",
|
||||
rational_value(exif, Tag::ExposureTime).map(format_exposure_time),
|
||||
);
|
||||
push_field(
|
||||
&mut fields,
|
||||
"光圈",
|
||||
rational_value(exif, Tag::FNumber).map(|value| format!("f/{}", compact_decimal(value))),
|
||||
);
|
||||
push_field(
|
||||
&mut fields,
|
||||
"ISO",
|
||||
unsigned_value(exif, Tag::PhotographicSensitivity)
|
||||
.or_else(|| unsigned_value(exif, Tag::ISOSpeed))
|
||||
.or_else(|| unsigned_value(exif, Tag::RecommendedExposureIndex))
|
||||
.map(|value| value.to_string()),
|
||||
);
|
||||
push_field(
|
||||
&mut fields,
|
||||
"焦距",
|
||||
rational_value(exif, Tag::FocalLength)
|
||||
.map(|value| format!("{} mm", compact_decimal(value))),
|
||||
);
|
||||
push_field(
|
||||
&mut fields,
|
||||
"35mm 等效",
|
||||
unsigned_value(exif, Tag::FocalLengthIn35mmFilm)
|
||||
.filter(|value| *value > 0)
|
||||
.map(|value| format!("{value} mm")),
|
||||
);
|
||||
push_field(
|
||||
&mut fields,
|
||||
"曝光补偿",
|
||||
signed_rational_value(exif, Tag::ExposureBiasValue).map(format_exposure_bias),
|
||||
);
|
||||
push_field(
|
||||
&mut fields,
|
||||
"曝光程序",
|
||||
unsigned_value(exif, Tag::ExposureProgram).map(exposure_program),
|
||||
);
|
||||
push_field(
|
||||
&mut fields,
|
||||
"测光模式",
|
||||
unsigned_value(exif, Tag::MeteringMode).map(metering_mode),
|
||||
);
|
||||
push_field(
|
||||
&mut fields,
|
||||
"闪光灯",
|
||||
unsigned_value(exif, Tag::Flash).map(|value| {
|
||||
if value & 1 == 1 {
|
||||
"已闪光".to_owned()
|
||||
} else {
|
||||
"未闪光".to_owned()
|
||||
}
|
||||
}),
|
||||
);
|
||||
push_field(
|
||||
&mut fields,
|
||||
"白平衡",
|
||||
unsigned_value(exif, Tag::WhiteBalance).map(|value| match value {
|
||||
0 => "自动".to_owned(),
|
||||
1 => "手动".to_owned(),
|
||||
_ => value.to_string(),
|
||||
}),
|
||||
);
|
||||
push_field(&mut fields, "处理软件", ascii_value(exif, Tag::Software));
|
||||
|
||||
fields
|
||||
}
|
||||
|
||||
fn push_field(fields: &mut Vec<ExifField>, label: &'static str, value: Option<String>) {
|
||||
let Some(value) = value.map(|value| value.trim().to_owned()) else {
|
||||
return;
|
||||
};
|
||||
if !value.is_empty() {
|
||||
fields.push(ExifField { label, value });
|
||||
}
|
||||
}
|
||||
|
||||
fn field_value(exif: &Exif, tag: Tag) -> Option<&Value> {
|
||||
exif.get_field(tag, In::PRIMARY).map(|field| &field.value)
|
||||
}
|
||||
|
||||
fn ascii_value(exif: &Exif, tag: Tag) -> Option<String> {
|
||||
let Value::Ascii(values) = field_value(exif, tag)? else {
|
||||
return None;
|
||||
};
|
||||
let value = values.first()?;
|
||||
let value = String::from_utf8_lossy(value)
|
||||
.trim_matches(|character: char| character == '\0' || character.is_whitespace())
|
||||
.to_owned();
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
fn unsigned_value(exif: &Exif, tag: Tag) -> Option<u32> {
|
||||
field_value(exif, tag)?.get_uint(0)
|
||||
}
|
||||
|
||||
fn rational_value(exif: &Exif, tag: Tag) -> Option<f64> {
|
||||
match field_value(exif, tag)? {
|
||||
Value::Rational(values) => values.first().map(|value| value.to_f64()),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
}
|
||||
|
||||
fn signed_rational_value(exif: &Exif, tag: Tag) -> Option<f64> {
|
||||
match field_value(exif, tag)? {
|
||||
Value::SRational(values) => values.first().map(|value| value.to_f64()),
|
||||
Value::Rational(values) => values.first().map(|value| value.to_f64()),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|value| value.is_finite())
|
||||
}
|
||||
|
||||
fn normalize_exif_datetime(value: &str) -> String {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() >= 10 && bytes.get(4) == Some(&b':') && bytes.get(7) == Some(&b':') {
|
||||
if let (Some(year), Some(month), Some(day), Some(rest)) = (
|
||||
value.get(..4),
|
||||
value.get(5..7),
|
||||
value.get(8..10),
|
||||
value.get(10..),
|
||||
) {
|
||||
return format!("{year}-{month}-{day}{rest}");
|
||||
}
|
||||
value.to_owned()
|
||||
} else {
|
||||
value.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_make_and_model(make: Option<String>, model: Option<String>) -> Option<String> {
|
||||
match (make, model) {
|
||||
(Some(make), Some(model)) if model.to_lowercase().starts_with(&make.to_lowercase()) => {
|
||||
Some(model)
|
||||
}
|
||||
(Some(make), Some(model)) => Some(format!("{make} {model}")),
|
||||
(Some(make), None) => Some(make),
|
||||
(None, Some(model)) => Some(model),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_exposure_time(seconds: f64) -> String {
|
||||
if seconds <= 0.0 {
|
||||
return "0 秒".to_owned();
|
||||
}
|
||||
if seconds < 1.0 {
|
||||
let denominator = (1.0 / seconds).round();
|
||||
let reciprocal = 1.0 / seconds;
|
||||
if denominator.is_finite()
|
||||
&& denominator >= 1.0
|
||||
&& (reciprocal - denominator).abs() <= (reciprocal * 0.005).max(0.01)
|
||||
{
|
||||
return format!("1/{} 秒", denominator as u64);
|
||||
}
|
||||
}
|
||||
format!("{} 秒", compact_decimal(seconds))
|
||||
}
|
||||
|
||||
fn format_exposure_bias(value: f64) -> String {
|
||||
if value.abs() < 0.005 {
|
||||
"0 EV".to_owned()
|
||||
} else {
|
||||
format!("{value:+.1} EV")
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_decimal(value: f64) -> String {
|
||||
if (value - value.round()).abs() < 0.005 {
|
||||
format!("{value:.0}")
|
||||
} else if value.abs() < 10.0 {
|
||||
format!("{value:.1}")
|
||||
} else {
|
||||
format!("{value:.0}")
|
||||
}
|
||||
}
|
||||
|
||||
fn exposure_program(value: u32) -> String {
|
||||
match value {
|
||||
0 => "未定义".to_owned(),
|
||||
1 => "手动".to_owned(),
|
||||
2 => "程序自动".to_owned(),
|
||||
3 => "光圈优先".to_owned(),
|
||||
4 => "快门优先".to_owned(),
|
||||
5 => "创意程序".to_owned(),
|
||||
6 => "动作程序".to_owned(),
|
||||
7 => "人像模式".to_owned(),
|
||||
8 => "风景模式".to_owned(),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn metering_mode(value: u32) -> String {
|
||||
match value {
|
||||
0 => "未知".to_owned(),
|
||||
1 => "平均".to_owned(),
|
||||
2 => "中央重点平均".to_owned(),
|
||||
3 => "点测光".to_owned(),
|
||||
4 => "多点测光".to_owned(),
|
||||
5 => "多区测光".to_owned(),
|
||||
6 => "局部测光".to_owned(),
|
||||
255 => "其他".to_owned(),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use exif::{Field, Rational};
|
||||
use image::ImageEncoder;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn histogram_counts_rgb_and_luminance_bins() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("histogram.png");
|
||||
let mut image = image::RgbImage::new(2, 1);
|
||||
image.put_pixel(0, 0, image::Rgb([255, 0, 0]));
|
||||
image.put_pixel(1, 0, image::Rgb([0, 255, 0]));
|
||||
image.save(&path).unwrap();
|
||||
|
||||
let histogram = read_histogram(&path).unwrap();
|
||||
assert_eq!(histogram.red[0], 1);
|
||||
assert_eq!(histogram.red[255], 1);
|
||||
assert_eq!(histogram.green[0], 1);
|
||||
assert_eq!(histogram.green[255], 1);
|
||||
assert_eq!(histogram.blue[0], 2);
|
||||
assert_eq!(histogram.luminance.iter().sum::<u32>(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_common_photography_values() {
|
||||
assert_eq!(
|
||||
normalize_exif_datetime("2026:08:29 14:03:02"),
|
||||
"2026-08-29 14:03:02"
|
||||
);
|
||||
assert_eq!(
|
||||
combine_make_and_model(Some("Canon".into()), Some("Canon EOS R6".into())),
|
||||
Some("Canon EOS R6".into())
|
||||
);
|
||||
assert_eq!(format_exposure_time(1.0 / 125.0), "1/125 秒");
|
||||
assert_eq!(format_exposure_time(1.3), "1.3 秒");
|
||||
assert_eq!(format_exposure_time(0.4), "0.4 秒");
|
||||
assert_eq!(format_exposure_bias(-2.0 / 3.0), "-0.7 EV");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_curated_fields_from_a_jpeg_container() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("camera.jpg");
|
||||
let fields = [
|
||||
Field {
|
||||
tag: Tag::Make,
|
||||
ifd_num: In::PRIMARY,
|
||||
value: Value::Ascii(vec![b"Canon".to_vec()]),
|
||||
},
|
||||
Field {
|
||||
tag: Tag::Model,
|
||||
ifd_num: In::PRIMARY,
|
||||
value: Value::Ascii(vec![b"Canon EOS R6".to_vec()]),
|
||||
},
|
||||
Field {
|
||||
tag: Tag::DateTimeOriginal,
|
||||
ifd_num: In::PRIMARY,
|
||||
value: Value::Ascii(vec![b"2026:08:29 14:03:02".to_vec()]),
|
||||
},
|
||||
Field {
|
||||
tag: Tag::ExposureTime,
|
||||
ifd_num: In::PRIMARY,
|
||||
value: Value::Rational(vec![Rational { num: 1, denom: 125 }]),
|
||||
},
|
||||
Field {
|
||||
tag: Tag::FNumber,
|
||||
ifd_num: In::PRIMARY,
|
||||
value: Value::Rational(vec![Rational { num: 28, denom: 10 }]),
|
||||
},
|
||||
Field {
|
||||
tag: Tag::PhotographicSensitivity,
|
||||
ifd_num: In::PRIMARY,
|
||||
value: Value::Short(vec![400]),
|
||||
},
|
||||
Field {
|
||||
tag: Tag::ColorSpace,
|
||||
ifd_num: In::PRIMARY,
|
||||
value: Value::Short(vec![1]),
|
||||
},
|
||||
];
|
||||
let mut metadata = Cursor::new(Vec::new());
|
||||
let mut writer = exif::experimental::Writer::new();
|
||||
for field in &fields {
|
||||
writer.push_field(field);
|
||||
}
|
||||
writer.write(&mut metadata, false).unwrap();
|
||||
|
||||
let file = File::create(&path).unwrap();
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(file, 90);
|
||||
encoder.set_exif_metadata(metadata.into_inner()).unwrap();
|
||||
encoder
|
||||
.write_image(
|
||||
&[180, 90, 30, 30, 90, 180],
|
||||
2,
|
||||
1,
|
||||
image::ExtendedColorType::Rgb8,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fields = read_exif(&path).unwrap();
|
||||
let value = |label| {
|
||||
fields
|
||||
.iter()
|
||||
.find(|field| field.label == label)
|
||||
.map(|field| field.value.as_str())
|
||||
};
|
||||
assert_eq!(value("拍摄时间"), Some("2026-08-29 14:03:02"));
|
||||
assert_eq!(value("相机"), Some("Canon EOS R6"));
|
||||
assert_eq!(value("快门"), Some("1/125 秒"));
|
||||
assert_eq!(value("光圈"), Some("f/2.8"));
|
||||
assert_eq!(value("ISO"), Some("400"));
|
||||
assert_eq!(value("色彩空间"), Some("sRGB"));
|
||||
}
|
||||
}
|
||||
+113
-204
@@ -11,21 +11,11 @@ use std::{
|
||||
Arc, Mutex, RwLock,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use std::fs;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use candle_core::{DType, Device, IndexOp, Tensor};
|
||||
use candle_nn::VarBuilder;
|
||||
use candle_transformers::models::chinese_clip::{ChineseClipConfig, ChineseClipModel};
|
||||
use image::{DynamicImage, GenericImageView, ImageReader, imageops::FilterType};
|
||||
use anyhow::{Result, anyhow};
|
||||
use serde::Serialize;
|
||||
use tokenizers::{
|
||||
Tokenizer, TruncationParams, models::wordpiece::WordPiece, normalizers::bert::BertNormalizer,
|
||||
pre_tokenizers::bert::BertPreTokenizer, processors::bert::BertProcessing,
|
||||
};
|
||||
use tokio::sync::Notify;
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -38,14 +28,22 @@ use crate::support::{
|
||||
mutex_lock, read_lock, write_lock,
|
||||
};
|
||||
|
||||
const MODEL_NAME: &str = "Chinese-CLIP ViT-B/16";
|
||||
const MODEL_EDGE: u32 = 224;
|
||||
const TEXT_CONTEXT_LENGTH: usize = 52;
|
||||
mod download;
|
||||
mod model;
|
||||
|
||||
pub(crate) use download::ensure_automatic_model;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use model::TextImageModel;
|
||||
use model::{LazyModel, MODEL_NAME};
|
||||
|
||||
const EMBEDDING_SIZE: usize = 512;
|
||||
const EMBEDDING_MAGIC: &[u8; 8] = b"PXHFCN1\0";
|
||||
const EMBEDDING_EXTENSION: &str = "cnclip";
|
||||
const MODEL_FINGERPRINT_BYTES: usize = FINGERPRINT_BYTES;
|
||||
const QUERY_CACHE_LIMIT: usize = 16;
|
||||
const MODEL_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const MODEL_IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) type TextSearchEmbedding = QuantizedEmbedding<EMBEDDING_SIZE>;
|
||||
|
||||
@@ -56,13 +54,8 @@ pub(crate) fn text_image_similarity(
|
||||
query.cosine_similarity(image)
|
||||
}
|
||||
|
||||
pub(crate) trait TextImageModel: Send {
|
||||
fn embed_thumbnail(&mut self, thumbnail: &Path) -> Result<TextSearchEmbedding>;
|
||||
fn embed_text(&mut self, text: &str) -> Result<TextSearchEmbedding>;
|
||||
}
|
||||
|
||||
pub(crate) struct TextSearchIndex {
|
||||
model: Arc<Mutex<Box<dyn TextImageModel>>>,
|
||||
model: Arc<Mutex<LazyModel>>,
|
||||
model_fingerprint: Fingerprint,
|
||||
model_token: String,
|
||||
entries: RwLock<HashMap<String, Arc<TextSearchEntry>>>,
|
||||
@@ -116,17 +109,13 @@ impl TextSearchIndex {
|
||||
pub(crate) fn load(model_path: &Path, vocabulary_path: &Path) -> Result<Arc<Self>> {
|
||||
let fingerprint =
|
||||
fingerprint_files(&[model_path, vocabulary_path], "text-search model file")?;
|
||||
let model = ChineseClip::load(model_path, vocabulary_path).with_context(|| {
|
||||
format!(
|
||||
"cannot load natural-language search model: {}",
|
||||
model_path.display()
|
||||
)
|
||||
})?;
|
||||
let index = Self::with_model(Box::new(model), fingerprint);
|
||||
let model = LazyModel::chinese_clip(model_path.to_owned(), vocabulary_path.to_owned());
|
||||
let index = Self::with_model(model, fingerprint);
|
||||
info!(
|
||||
model = MODEL_NAME,
|
||||
path = %model_path.display(),
|
||||
fingerprint = %index.model_token,
|
||||
"local natural-language image search enabled"
|
||||
"local natural-language image search enabled; model will load on demand"
|
||||
);
|
||||
Ok(index)
|
||||
}
|
||||
@@ -136,13 +125,18 @@ impl TextSearchIndex {
|
||||
model: Box<dyn TextImageModel>,
|
||||
model_fingerprint: [u8; MODEL_FINGERPRINT_BYTES],
|
||||
) -> Arc<Self> {
|
||||
Self::with_model(model, model_fingerprint)
|
||||
Self::with_model(LazyModel::preloaded(model), model_fingerprint)
|
||||
}
|
||||
|
||||
fn with_model(
|
||||
model: Box<dyn TextImageModel>,
|
||||
#[cfg(test)]
|
||||
fn with_test_loader(
|
||||
loader: impl Fn() -> Result<Box<dyn TextImageModel>> + Send + Sync + 'static,
|
||||
model_fingerprint: [u8; MODEL_FINGERPRINT_BYTES],
|
||||
) -> Arc<Self> {
|
||||
Self::with_model(LazyModel::with_loader(loader), model_fingerprint)
|
||||
}
|
||||
|
||||
fn with_model(model: LazyModel, model_fingerprint: [u8; MODEL_FINGERPRINT_BYTES]) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
model: Arc::new(Mutex::new(model)),
|
||||
model_fingerprint,
|
||||
@@ -163,6 +157,10 @@ impl TextSearchIndex {
|
||||
tokio::spawn(async move {
|
||||
index.worker_loop().await;
|
||||
});
|
||||
let index = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
index.model_reaper_loop().await;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile(&self, valid_ids: &HashSet<&str>) {
|
||||
@@ -275,6 +273,24 @@ impl TextSearchIndex {
|
||||
}
|
||||
}
|
||||
|
||||
async fn model_reaper_loop(self: Arc<Self>) {
|
||||
loop {
|
||||
tokio::time::sleep(MODEL_IDLE_CHECK_INTERVAL).await;
|
||||
if mutex_lock(&self.model).release_if_idle(MODEL_IDLE_TIMEOUT) {
|
||||
info!(
|
||||
model = MODEL_NAME,
|
||||
idle_seconds = MODEL_IDLE_TIMEOUT.as_secs(),
|
||||
"released idle natural-language search model"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn release_model_for_test(&self) -> bool {
|
||||
mutex_lock(&self.model).release_if_idle(Duration::ZERO)
|
||||
}
|
||||
|
||||
async fn worker_loop(self: Arc<Self>) {
|
||||
loop {
|
||||
let id = self.queue.pop().await;
|
||||
@@ -376,129 +392,6 @@ fn write_embedding(
|
||||
)
|
||||
}
|
||||
|
||||
struct ChineseClip {
|
||||
model: ChineseClipModel,
|
||||
tokenizer: Tokenizer,
|
||||
device: Device,
|
||||
}
|
||||
|
||||
impl ChineseClip {
|
||||
fn load(model_path: &Path, vocabulary_path: &Path) -> Result<Self> {
|
||||
let device = Device::Cpu;
|
||||
// SAFETY: the mapped model is immutable for the process lifetime. The
|
||||
// documentation requires stopping Pixhelf before replacing it.
|
||||
let variables =
|
||||
unsafe { VarBuilder::from_mmaped_safetensors(&[model_path], DType::F32, &device)? };
|
||||
let config = ChineseClipConfig::clip_vit_base_patch16();
|
||||
let model = ChineseClipModel::new(variables, &config)?;
|
||||
let tokenizer = load_tokenizer(vocabulary_path)?;
|
||||
Ok(Self {
|
||||
model,
|
||||
tokenizer,
|
||||
device,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TextImageModel for ChineseClip {
|
||||
fn embed_thumbnail(&mut self, thumbnail: &Path) -> Result<TextSearchEmbedding> {
|
||||
let input = preprocess_thumbnail(thumbnail)?;
|
||||
let values = self
|
||||
.model
|
||||
.get_image_features(&input)?
|
||||
.i(0)?
|
||||
.to_vec1::<f32>()?;
|
||||
TextSearchEmbedding::quantize(&values, "text-search image model")
|
||||
}
|
||||
|
||||
fn embed_text(&mut self, text: &str) -> Result<TextSearchEmbedding> {
|
||||
let encoding = self
|
||||
.tokenizer
|
||||
.encode(text, true)
|
||||
.map_err(|error| anyhow!("cannot tokenize text query: {error}"))?;
|
||||
if encoding.is_empty() {
|
||||
bail!("text query did not produce any tokens");
|
||||
}
|
||||
let input_ids = Tensor::new(encoding.get_ids(), &self.device)?.unsqueeze(0)?;
|
||||
let token_type_ids = Tensor::new(encoding.get_type_ids(), &self.device)?.unsqueeze(0)?;
|
||||
let attention_mask =
|
||||
Tensor::new(encoding.get_attention_mask(), &self.device)?.unsqueeze(0)?;
|
||||
let values = self
|
||||
.model
|
||||
.get_text_features(&input_ids, Some(&token_type_ids), Some(&attention_mask))?
|
||||
.i(0)?
|
||||
.to_vec1::<f32>()?;
|
||||
TextSearchEmbedding::quantize(&values, "text-search text model")
|
||||
}
|
||||
}
|
||||
|
||||
fn load_tokenizer(vocabulary_path: &Path) -> Result<Tokenizer> {
|
||||
let vocabulary = vocabulary_path.to_str().with_context(|| {
|
||||
format!(
|
||||
"vocabulary path is not UTF-8: {}",
|
||||
vocabulary_path.display()
|
||||
)
|
||||
})?;
|
||||
let model = WordPiece::from_file(vocabulary)
|
||||
.unk_token("[UNK]".to_owned())
|
||||
.build()
|
||||
.map_err(|error| anyhow!("cannot load Chinese-CLIP vocabulary: {error}"))?;
|
||||
let mut tokenizer = Tokenizer::new(model);
|
||||
let cls_id = tokenizer
|
||||
.token_to_id("[CLS]")
|
||||
.context("Chinese-CLIP vocabulary is missing [CLS]")?;
|
||||
let sep_id = tokenizer
|
||||
.token_to_id("[SEP]")
|
||||
.context("Chinese-CLIP vocabulary is missing [SEP]")?;
|
||||
tokenizer.with_normalizer(Some(BertNormalizer::default()));
|
||||
tokenizer.with_pre_tokenizer(Some(BertPreTokenizer));
|
||||
tokenizer.with_post_processor(Some(BertProcessing::new(
|
||||
("[SEP]".to_owned(), sep_id),
|
||||
("[CLS]".to_owned(), cls_id),
|
||||
)));
|
||||
tokenizer
|
||||
.with_truncation(Some(TruncationParams {
|
||||
max_length: TEXT_CONTEXT_LENGTH,
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|error| anyhow!("cannot configure Chinese-CLIP tokenizer: {error}"))?;
|
||||
Ok(tokenizer)
|
||||
}
|
||||
|
||||
fn preprocess_thumbnail(path: &Path) -> Result<Tensor> {
|
||||
let image = ImageReader::open(path)
|
||||
.with_context(|| format!("cannot open thumbnail {}", path.display()))?
|
||||
.with_guessed_format()?
|
||||
.decode()
|
||||
.with_context(|| format!("cannot decode thumbnail {}", path.display()))?;
|
||||
preprocess_image(&image)
|
||||
}
|
||||
|
||||
fn preprocess_image(image: &DynamicImage) -> Result<Tensor> {
|
||||
let (width, height) = image.dimensions();
|
||||
if width == 0 || height == 0 {
|
||||
bail!("cannot embed an empty image");
|
||||
}
|
||||
// The official ChineseCLIPFeatureExtractor resizes directly to 224x224
|
||||
// and does not centre-crop this checkpoint.
|
||||
let pixels = image
|
||||
.resize_exact(MODEL_EDGE, MODEL_EDGE, FilterType::CatmullRom)
|
||||
.to_rgb8();
|
||||
const MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73];
|
||||
const STANDARD_DEVIATION: [f32; 3] = [0.268_629_54, 0.261_302_6, 0.275_777_1];
|
||||
let mut values = Vec::with_capacity((MODEL_EDGE * MODEL_EDGE * 3) as usize);
|
||||
for channel in 0..3 {
|
||||
values.extend(pixels.pixels().map(|pixel| {
|
||||
(f32::from(pixel.0[channel]) / 255.0 - MEAN[channel]) / STANDARD_DEVIATION[channel]
|
||||
}));
|
||||
}
|
||||
Ok(Tensor::from_vec(
|
||||
values,
|
||||
(1, 3, MODEL_EDGE as usize, MODEL_EDGE as usize),
|
||||
&Device::Cpu,
|
||||
)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -589,59 +482,75 @@ mod tests {
|
||||
assert!(embedding_path(&thumbnail).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenizer_uses_chinese_bert_special_tokens() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("vocab.txt");
|
||||
let mut vocabulary = vec!["[PAD]".to_owned()];
|
||||
vocabulary.extend((0..99).map(|index| format!("[unused{index}]")));
|
||||
vocabulary.extend(
|
||||
["[UNK]", "[CLS]", "[SEP]", "[MASK]", "海", "边", "日", "落"].map(str::to_owned),
|
||||
#[tokio::test]
|
||||
async fn reloadable_model_loads_on_demand_and_can_be_released() {
|
||||
let loads = Arc::new(AtomicUsize::new(0));
|
||||
let image_calls = Arc::new(AtomicUsize::new(0));
|
||||
let text_calls = Arc::new(AtomicUsize::new(0));
|
||||
let index = TextSearchIndex::with_test_loader(
|
||||
{
|
||||
let loads = Arc::clone(&loads);
|
||||
let image_calls = Arc::clone(&image_calls);
|
||||
let text_calls = Arc::clone(&text_calls);
|
||||
move || {
|
||||
loads.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(Box::new(ColourAndWordModel {
|
||||
image_calls: Arc::clone(&image_calls),
|
||||
text_calls: Arc::clone(&text_calls),
|
||||
}))
|
||||
}
|
||||
},
|
||||
[10; MODEL_FINGERPRINT_BYTES],
|
||||
);
|
||||
fs::write(&path, format!("{}\n", vocabulary.join("\n"))).unwrap();
|
||||
let tokenizer = load_tokenizer(&path).unwrap();
|
||||
let encoding = tokenizer.encode("海边日落", true).unwrap();
|
||||
assert_eq!(encoding.get_ids().first(), Some(&101));
|
||||
assert_eq!(encoding.get_ids().last(), Some(&102));
|
||||
assert!(encoding.len() >= 6);
|
||||
|
||||
assert_eq!(loads.load(Ordering::Relaxed), 0);
|
||||
index.embed_query("红色图片").await.unwrap();
|
||||
assert_eq!(loads.load(Ordering::Relaxed), 1);
|
||||
assert!(index.release_model_for_test());
|
||||
|
||||
index.embed_query("blue image").await.unwrap();
|
||||
assert_eq!(loads.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(text_calls.load(Ordering::Relaxed), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocessing_matches_official_chinese_clip_shape() {
|
||||
let image = DynamicImage::ImageRgb8(image::RgbImage::from_fn(400, 200, |x, y| {
|
||||
image::Rgb([(x % 255) as u8, (y % 255) as u8, 128])
|
||||
}));
|
||||
let tensor = preprocess_image(&image).unwrap();
|
||||
assert_eq!(tensor.dims(), &[1, 3, 224, 224]);
|
||||
assert!(tensor.flatten_all().unwrap().to_vec1::<f32>().unwrap()[0].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_model_runs_when_supplied_by_the_test_environment() {
|
||||
let (Some(model_path), Some(vocabulary_path)) = (
|
||||
std::env::var_os("PIXHELF_CHINESE_CLIP_MODEL"),
|
||||
std::env::var_os("PIXHELF_CHINESE_CLIP_VOCAB"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
#[tokio::test]
|
||||
async fn cached_image_embedding_does_not_load_model() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let red = temp.path().join("red.png");
|
||||
let blue = temp.path().join("blue.png");
|
||||
image::RgbImage::from_pixel(224, 224, image::Rgb([235, 30, 25]))
|
||||
.save(&red)
|
||||
.unwrap();
|
||||
image::RgbImage::from_pixel(224, 224, image::Rgb([25, 40, 235]))
|
||||
.save(&blue)
|
||||
.unwrap();
|
||||
let thumbnail = temp.path().join("cached.webp");
|
||||
let fingerprint = [11; MODEL_FINGERPRINT_BYTES];
|
||||
write_embedding(
|
||||
&embedding_path(&thumbnail),
|
||||
&fingerprint,
|
||||
&TextSearchEmbedding::for_test([(0, 100)]),
|
||||
)
|
||||
.unwrap();
|
||||
let loads = Arc::new(AtomicUsize::new(0));
|
||||
let index = TextSearchIndex::with_test_loader(
|
||||
{
|
||||
let loads = Arc::clone(&loads);
|
||||
move || {
|
||||
loads.fetch_add(1, Ordering::Relaxed);
|
||||
Ok(Box::new(ColourAndWordModel {
|
||||
image_calls: Arc::new(AtomicUsize::new(0)),
|
||||
text_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}))
|
||||
}
|
||||
},
|
||||
fingerprint,
|
||||
);
|
||||
index.reconcile(&HashSet::from(["cached"]));
|
||||
index.enqueue("cached", &thumbnail, false);
|
||||
index.start_worker();
|
||||
|
||||
let mut model =
|
||||
ChineseClip::load(Path::new(&model_path), Path::new(&vocabulary_path)).unwrap();
|
||||
let query = model.embed_text("一张红色的图片").unwrap();
|
||||
let red = model.embed_thumbnail(&red).unwrap();
|
||||
let blue = model.embed_thumbnail(&blue).unwrap();
|
||||
let red_score = text_image_similarity(&query, &red);
|
||||
let blue_score = text_image_similarity(&query, &blue);
|
||||
eprintln!("official Chinese-CLIP scores: red={red_score:.4}, blue={blue_score:.4}");
|
||||
assert!(red_score > blue_score);
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !index.status().background_complete {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cached text-search index timed out");
|
||||
|
||||
assert!(index.embedding("cached").is_some());
|
||||
assert_eq!(loads.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
io::{ErrorKind, Read},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use reqwest::{Client, StatusCode, header::RANGE};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::{fs, io::AsyncWriteExt};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::TextSearchFiles;
|
||||
|
||||
const MODEL_REVISION: &str = "f4a64596bbcf9a2a94591b74b9dc39b2e4e77e3e";
|
||||
const MODEL_BASE_URL: &str = "https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16/resolve";
|
||||
const PROGRESS_INTERVAL: u64 = 64 * 1024 * 1024;
|
||||
const DOWNLOAD_ATTEMPTS: usize = 5;
|
||||
|
||||
const ARTIFACTS: [Artifact; 2] = [
|
||||
Artifact {
|
||||
kind: ArtifactKind::Model,
|
||||
filename: "model.safetensors",
|
||||
sha256: "29cc0b2bcf6ff777f2e15742be92b110e4acbdb2068356e862c4637a4b15fe4f",
|
||||
size: 753_106_020,
|
||||
},
|
||||
Artifact {
|
||||
kind: ArtifactKind::Vocabulary,
|
||||
filename: "vocab.txt",
|
||||
sha256: "45bbac6b341c319adc98a532532882e91a9cefc0329aa57bac9ae761c27b291c",
|
||||
size: 109_540,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Artifact {
|
||||
kind: ArtifactKind,
|
||||
filename: &'static str,
|
||||
sha256: &'static str,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ArtifactKind {
|
||||
Model,
|
||||
Vocabulary,
|
||||
}
|
||||
|
||||
impl Artifact {
|
||||
fn destination<'a>(&self, files: &'a TextSearchFiles) -> &'a Path {
|
||||
match self.kind {
|
||||
ArtifactKind::Model => &files.model,
|
||||
ArtifactKind::Vocabulary => &files.vocabulary,
|
||||
}
|
||||
}
|
||||
|
||||
fn url(&self) -> String {
|
||||
format!("{MODEL_BASE_URL}/{MODEL_REVISION}/{}", self.filename)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_automatic_model(files: &TextSearchFiles) -> Result<()> {
|
||||
let mut missing = Vec::new();
|
||||
for artifact in ARTIFACTS {
|
||||
let destination = artifact.destination(files);
|
||||
if cached_artifact_is_valid(destination, artifact).await? {
|
||||
info!(file = %destination.display(), "using cached model file");
|
||||
} else {
|
||||
missing.push(artifact);
|
||||
}
|
||||
}
|
||||
|
||||
if missing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for destination in [&files.model, &files.vocabulary] {
|
||||
let parent = destination.parent().with_context(|| {
|
||||
format!("model cache path has no parent: {}", destination.display())
|
||||
})?;
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("cannot create model cache: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.user_agent(concat!("pixhelf/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.context("cannot initialise the model downloader")?;
|
||||
for artifact in missing {
|
||||
let url = artifact.url();
|
||||
download_artifact(&client, artifact.destination(files), artifact, &url).await?;
|
||||
}
|
||||
|
||||
info!(
|
||||
directory = %files.model.parent().unwrap_or(&files.model).display(),
|
||||
"natural-language search model downloaded"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_artifact(
|
||||
client: &Client,
|
||||
destination: &Path,
|
||||
artifact: Artifact,
|
||||
url: &str,
|
||||
) -> Result<()> {
|
||||
for attempt in 1..=DOWNLOAD_ATTEMPTS {
|
||||
let result = download_artifact_once(client, destination, artifact, url).await;
|
||||
if result.is_ok() || attempt == DOWNLOAD_ATTEMPTS {
|
||||
return result.with_context(|| {
|
||||
format!(
|
||||
"failed to download {} after {attempt} attempts",
|
||||
artifact.filename
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let error = result.expect_err("failed download must contain an error");
|
||||
let retry_seconds = 1_u64 << (attempt - 1);
|
||||
warn!(
|
||||
file = artifact.filename,
|
||||
attempt,
|
||||
retry_seconds,
|
||||
error = %format!("{error:#}"),
|
||||
"model download failed; retrying"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(retry_seconds)).await;
|
||||
}
|
||||
unreachable!("download attempt loop always returns")
|
||||
}
|
||||
|
||||
async fn download_artifact_once(
|
||||
client: &Client,
|
||||
destination: &Path,
|
||||
artifact: Artifact,
|
||||
url: &str,
|
||||
) -> Result<()> {
|
||||
let partial = suffixed_path(destination, ".part");
|
||||
let mut downloaded = file_size_or_zero(&partial).await?;
|
||||
|
||||
if downloaded == artifact.size {
|
||||
if sha256_file(partial.clone()).await? == artifact.sha256 {
|
||||
install_artifact(&partial, destination, artifact.sha256).await?;
|
||||
return Ok(());
|
||||
}
|
||||
fs::remove_file(&partial)
|
||||
.await
|
||||
.with_context(|| format!("cannot remove invalid download: {}", partial.display()))?;
|
||||
downloaded = 0;
|
||||
} else if downloaded > artifact.size {
|
||||
fs::remove_file(&partial)
|
||||
.await
|
||||
.with_context(|| format!("cannot remove invalid download: {}", partial.display()))?;
|
||||
downloaded = 0;
|
||||
}
|
||||
|
||||
let resumed_from = downloaded;
|
||||
let mut request = client.get(url);
|
||||
if resumed_from > 0 {
|
||||
request = request.header(RANGE, format!("bytes={resumed_from}-"));
|
||||
}
|
||||
|
||||
info!(
|
||||
file = artifact.filename,
|
||||
downloaded_bytes = resumed_from,
|
||||
total_bytes = artifact.size,
|
||||
"downloading natural-language search model"
|
||||
);
|
||||
let mut response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("cannot download {url}"))?;
|
||||
if resumed_from > 0 && response.status() != StatusCode::PARTIAL_CONTENT {
|
||||
info!(
|
||||
file = artifact.filename,
|
||||
"download source does not support this range request; restarting the file"
|
||||
);
|
||||
response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("cannot restart download {url}"))?;
|
||||
}
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
bail!("model download returned HTTP {status}: {url}");
|
||||
}
|
||||
|
||||
let resuming = resumed_from > 0 && status == StatusCode::PARTIAL_CONTENT;
|
||||
if !resuming {
|
||||
downloaded = 0;
|
||||
}
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.create(true).write(true);
|
||||
if resuming {
|
||||
options.append(true);
|
||||
} else {
|
||||
options.truncate(true);
|
||||
}
|
||||
let mut output = options
|
||||
.open(&partial)
|
||||
.await
|
||||
.with_context(|| format!("cannot open model download: {}", partial.display()))?;
|
||||
let mut next_progress = downloaded.saturating_add(PROGRESS_INTERVAL);
|
||||
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.with_context(|| format!("model download interrupted: {}", artifact.filename))?
|
||||
{
|
||||
output
|
||||
.write_all(&chunk)
|
||||
.await
|
||||
.with_context(|| format!("cannot write model download: {}", partial.display()))?;
|
||||
downloaded = downloaded.saturating_add(chunk.len() as u64);
|
||||
if downloaded > artifact.size {
|
||||
bail!(
|
||||
"downloaded {} is larger than expected ({} > {} bytes)",
|
||||
artifact.filename,
|
||||
downloaded,
|
||||
artifact.size
|
||||
);
|
||||
}
|
||||
if downloaded >= next_progress || downloaded == artifact.size {
|
||||
info!(
|
||||
file = artifact.filename,
|
||||
percent = downloaded.saturating_mul(100) / artifact.size,
|
||||
downloaded_bytes = downloaded,
|
||||
total_bytes = artifact.size,
|
||||
"model download progress"
|
||||
);
|
||||
next_progress = downloaded.saturating_add(PROGRESS_INTERVAL);
|
||||
}
|
||||
}
|
||||
output
|
||||
.sync_all()
|
||||
.await
|
||||
.with_context(|| format!("cannot save model download: {}", partial.display()))?;
|
||||
drop(output);
|
||||
|
||||
if downloaded != artifact.size {
|
||||
bail!(
|
||||
"downloaded {} has the wrong size ({} of {} bytes); restart Pixhelf to resume",
|
||||
artifact.filename,
|
||||
downloaded,
|
||||
artifact.size
|
||||
);
|
||||
}
|
||||
|
||||
let checksum = sha256_file(partial.clone()).await?;
|
||||
if checksum != artifact.sha256 {
|
||||
fs::remove_file(&partial).await.with_context(|| {
|
||||
format!(
|
||||
"cannot remove checksum-mismatched file: {}",
|
||||
partial.display()
|
||||
)
|
||||
})?;
|
||||
bail!(
|
||||
"downloaded {} failed SHA-256 verification",
|
||||
artifact.filename
|
||||
);
|
||||
}
|
||||
|
||||
install_artifact(&partial, destination, artifact.sha256).await
|
||||
}
|
||||
|
||||
async fn cached_artifact_is_valid(path: &Path, artifact: Artifact) -> Result<bool> {
|
||||
let metadata = match fs::metadata(path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.with_context(|| format!("cannot inspect cached model: {}", path.display()));
|
||||
}
|
||||
};
|
||||
if !metadata.is_file() || metadata.len() != artifact.size {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let marker = checksum_marker(path);
|
||||
if let Ok(recorded) = fs::read_to_string(&marker).await
|
||||
&& recorded.trim() == artifact.sha256
|
||||
&& marker_is_current(&marker, &metadata).await
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if sha256_file(path.to_path_buf()).await? != artifact.sha256 {
|
||||
return Ok(false);
|
||||
}
|
||||
write_checksum_marker(path, artifact.sha256).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn marker_is_current(marker: &Path, artifact: &std::fs::Metadata) -> bool {
|
||||
let Ok(marker) = fs::metadata(marker).await else {
|
||||
return false;
|
||||
};
|
||||
match (marker.modified(), artifact.modified()) {
|
||||
(Ok(marker_time), Ok(artifact_time)) => marker_time >= artifact_time,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn file_size_or_zero(path: &Path) -> Result<u64> {
|
||||
match fs::metadata(path).await {
|
||||
Ok(metadata) if metadata.is_file() => Ok(metadata.len()),
|
||||
Ok(_) => bail!("model download path is not a file: {}", path.display()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(0),
|
||||
Err(error) => {
|
||||
Err(error).with_context(|| format!("cannot inspect download: {}", path.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sha256_file(path: PathBuf) -> Result<String> {
|
||||
let display = path.display().to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut file = std::fs::File::open(&path)
|
||||
.with_context(|| format!("cannot open file for verification: {display}"))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = vec![0_u8; 1024 * 1024];
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.with_context(|| format!("cannot verify file: {display}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
})
|
||||
.await
|
||||
.context("model verification task stopped")?
|
||||
}
|
||||
|
||||
async fn install_artifact(partial: &Path, destination: &Path, checksum: &str) -> Result<()> {
|
||||
if fs::try_exists(destination).await.with_context(|| {
|
||||
format!(
|
||||
"cannot inspect model destination: {}",
|
||||
destination.display()
|
||||
)
|
||||
})? {
|
||||
fs::remove_file(destination)
|
||||
.await
|
||||
.with_context(|| format!("cannot replace cached model: {}", destination.display()))?;
|
||||
}
|
||||
fs::rename(partial, destination).await.with_context(|| {
|
||||
format!(
|
||||
"cannot install downloaded model: {} -> {}",
|
||||
partial.display(),
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
write_checksum_marker(destination, checksum).await
|
||||
}
|
||||
|
||||
async fn write_checksum_marker(path: &Path, checksum: &str) -> Result<()> {
|
||||
let marker = checksum_marker(path);
|
||||
let temporary = suffixed_path(&marker, ".tmp");
|
||||
fs::write(&temporary, format!("{checksum}\n"))
|
||||
.await
|
||||
.with_context(|| format!("cannot write model checksum: {}", temporary.display()))?;
|
||||
if fs::try_exists(&marker)
|
||||
.await
|
||||
.with_context(|| format!("cannot inspect model checksum: {}", marker.display()))?
|
||||
{
|
||||
fs::remove_file(&marker)
|
||||
.await
|
||||
.with_context(|| format!("cannot replace model checksum: {}", marker.display()))?;
|
||||
}
|
||||
fs::rename(&temporary, &marker)
|
||||
.await
|
||||
.with_context(|| format!("cannot save model checksum: {}", marker.display()))
|
||||
}
|
||||
|
||||
fn checksum_marker(path: &Path) -> PathBuf {
|
||||
suffixed_path(path, ".sha256")
|
||||
}
|
||||
|
||||
fn suffixed_path(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut value = OsString::from(path.as_os_str());
|
||||
value.push(suffix);
|
||||
PathBuf::from(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_ARTIFACT: Artifact = Artifact {
|
||||
kind: ArtifactKind::Model,
|
||||
filename: "model.safetensors",
|
||||
sha256: "9372c470eeadd5ecd9c3c74c2b3cb633f8e2f2fad799250a0f70d652b6b825e4",
|
||||
size: 5,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn verifies_and_marks_an_existing_cached_file() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join(TEST_ARTIFACT.filename);
|
||||
fs::write(&path, b"model").await.unwrap();
|
||||
|
||||
assert!(
|
||||
cached_artifact_is_valid(&path, TEST_ARTIFACT)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(checksum_marker(&path))
|
||||
.await
|
||||
.unwrap()
|
||||
.trim(),
|
||||
TEST_ARTIFACT.sha256
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_same_sized_corrupt_cached_file() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join(TEST_ARTIFACT.filename);
|
||||
fs::write(&path, b"wrong").await.unwrap();
|
||||
|
||||
assert!(
|
||||
!cached_artifact_is_valid(&path, TEST_ARTIFACT)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(!checksum_marker(&path).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires access to the official model repository"]
|
||||
async fn downloads_and_verifies_the_official_vocabulary() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let artifact = ARTIFACTS[1];
|
||||
let path = temp.path().join(artifact.filename);
|
||||
let client = Client::builder()
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let url = artifact.url();
|
||||
download_artifact(&client, &path, artifact, &url)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(cached_artifact_is_valid(&path, artifact).await.unwrap());
|
||||
|
||||
let content = fs::read(&path).await.unwrap();
|
||||
fs::remove_file(&path).await.unwrap();
|
||||
fs::remove_file(checksum_marker(&path)).await.unwrap();
|
||||
fs::write(suffixed_path(&path, ".part"), &content[..content.len() / 2])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
download_artifact(&client, &path, artifact, &url)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fs::read(&path).await.unwrap(), content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "downloads and loads the complete 753 MB Hugging Face checkpoint"]
|
||||
async fn downloads_and_loads_the_complete_huggingface_checkpoint() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let files = TextSearchFiles {
|
||||
model: temp.path().join(ARTIFACTS[0].filename),
|
||||
vocabulary: temp.path().join(ARTIFACTS[1].filename),
|
||||
};
|
||||
|
||||
ensure_automatic_model(&files).await.unwrap();
|
||||
|
||||
let model = files.model.clone();
|
||||
let vocabulary = files.vocabulary.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::text_search::model::validate_checkpoint(&model, &vocabulary)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_the_pinned_huggingface_checkpoint() {
|
||||
let artifact = ARTIFACTS[0];
|
||||
let url = artifact.url();
|
||||
assert!(url.starts_with("https://huggingface.co/OFA-Sys/"));
|
||||
assert!(url.contains(MODEL_REVISION));
|
||||
assert!(url.ends_with("/model.safetensors"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Chinese-CLIP loading, preprocessing, and idle model lifecycle.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use candle_core::{DType, Device, IndexOp, Tensor};
|
||||
use candle_nn::VarBuilder;
|
||||
use candle_transformers::models::chinese_clip::{ChineseClipConfig, ChineseClipModel};
|
||||
use image::{DynamicImage, GenericImageView, ImageReader, imageops::FilterType};
|
||||
use tokenizers::{
|
||||
Tokenizer, TruncationParams, models::wordpiece::WordPiece, normalizers::bert::BertNormalizer,
|
||||
pre_tokenizers::bert::BertPreTokenizer, processors::bert::BertProcessing,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
use super::TextSearchEmbedding;
|
||||
|
||||
pub(super) const MODEL_NAME: &str = "Chinese-CLIP ViT-B/16";
|
||||
const MODEL_EDGE: u32 = 224;
|
||||
const TEXT_CONTEXT_LENGTH: usize = 52;
|
||||
|
||||
pub(crate) trait TextImageModel: Send {
|
||||
fn embed_thumbnail(&mut self, thumbnail: &Path) -> Result<TextSearchEmbedding>;
|
||||
fn embed_text(&mut self, text: &str) -> Result<TextSearchEmbedding>;
|
||||
}
|
||||
|
||||
type ModelLoader = dyn Fn() -> Result<Box<dyn TextImageModel>> + Send + Sync + 'static;
|
||||
|
||||
pub(super) struct LazyModel {
|
||||
current: Option<Box<dyn TextImageModel>>,
|
||||
loader: Option<Arc<ModelLoader>>,
|
||||
load_error: Option<String>,
|
||||
last_used: Option<Instant>,
|
||||
}
|
||||
|
||||
impl LazyModel {
|
||||
pub(super) fn chinese_clip(model_path: PathBuf, vocabulary_path: PathBuf) -> Self {
|
||||
Self::reloadable(Arc::new(move || {
|
||||
let model = ChineseClip::load(&model_path, &vocabulary_path).with_context(|| {
|
||||
format!(
|
||||
"cannot load natural-language search model: {}",
|
||||
model_path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(Box::new(model))
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn preloaded(model: Box<dyn TextImageModel>) -> Self {
|
||||
Self {
|
||||
current: Some(model),
|
||||
loader: None,
|
||||
load_error: None,
|
||||
last_used: Some(Instant::now()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn with_loader(
|
||||
loader: impl Fn() -> Result<Box<dyn TextImageModel>> + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self::reloadable(Arc::new(loader))
|
||||
}
|
||||
|
||||
fn reloadable(loader: Arc<ModelLoader>) -> Self {
|
||||
Self {
|
||||
current: None,
|
||||
loader: Some(loader),
|
||||
load_error: None,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn embed_thumbnail(&mut self, path: &Path) -> Result<TextSearchEmbedding> {
|
||||
self.run(|model| model.embed_thumbnail(path))
|
||||
}
|
||||
|
||||
pub(super) fn embed_text(&mut self, text: &str) -> Result<TextSearchEmbedding> {
|
||||
self.run(|model| model.embed_text(text))
|
||||
}
|
||||
|
||||
fn run<T>(
|
||||
&mut self,
|
||||
operation: impl FnOnce(&mut dyn TextImageModel) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
self.ensure_loaded()?;
|
||||
let result = operation(self.current.as_deref_mut().expect("model must be loaded"));
|
||||
self.last_used = Some(Instant::now());
|
||||
result
|
||||
}
|
||||
|
||||
fn ensure_loaded(&mut self) -> Result<()> {
|
||||
if self.current.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(error) = &self.load_error {
|
||||
bail!("natural-language search model is unavailable: {error}");
|
||||
}
|
||||
let loader = self
|
||||
.loader
|
||||
.as_ref()
|
||||
.context("natural-language search model cannot be reloaded")?
|
||||
.clone();
|
||||
info!(
|
||||
model = MODEL_NAME,
|
||||
"loading natural-language search model on demand"
|
||||
);
|
||||
match loader() {
|
||||
Ok(model) => {
|
||||
self.current = Some(model);
|
||||
self.last_used = Some(Instant::now());
|
||||
info!(model = MODEL_NAME, "natural-language search model loaded");
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("{error:#}");
|
||||
self.load_error = Some(message.clone());
|
||||
Err(anyhow!(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn release_if_idle(&mut self, timeout: Duration) -> bool {
|
||||
if self.loader.is_none() || self.current.is_none() {
|
||||
return false;
|
||||
}
|
||||
if !self
|
||||
.last_used
|
||||
.is_some_and(|last_used| last_used.elapsed() >= timeout)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.current = None;
|
||||
self.last_used = None;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct ChineseClip {
|
||||
model: ChineseClipModel,
|
||||
tokenizer: Tokenizer,
|
||||
device: Device,
|
||||
}
|
||||
|
||||
impl ChineseClip {
|
||||
fn load(model_path: &Path, vocabulary_path: &Path) -> Result<Self> {
|
||||
let device = Device::Cpu;
|
||||
// SAFETY: Pixhelf treats the configured model as immutable while the
|
||||
// process is running. Reloads use the same canonical file.
|
||||
let variables =
|
||||
unsafe { VarBuilder::from_mmaped_safetensors(&[model_path], DType::F32, &device)? };
|
||||
let config = ChineseClipConfig::clip_vit_base_patch16();
|
||||
Ok(Self {
|
||||
model: ChineseClipModel::new(variables, &config)?,
|
||||
tokenizer: load_tokenizer(vocabulary_path)?,
|
||||
device,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn validate_checkpoint(model_path: &Path, vocabulary_path: &Path) -> Result<()> {
|
||||
ChineseClip::load(model_path, vocabulary_path).map(|_| ())
|
||||
}
|
||||
|
||||
impl TextImageModel for ChineseClip {
|
||||
fn embed_thumbnail(&mut self, thumbnail: &Path) -> Result<TextSearchEmbedding> {
|
||||
let input = preprocess_thumbnail(thumbnail)?;
|
||||
let values = self
|
||||
.model
|
||||
.get_image_features(&input)?
|
||||
.i(0)?
|
||||
.to_vec1::<f32>()?;
|
||||
TextSearchEmbedding::quantize(&values, "text-search image model")
|
||||
}
|
||||
|
||||
fn embed_text(&mut self, text: &str) -> Result<TextSearchEmbedding> {
|
||||
let encoding = self
|
||||
.tokenizer
|
||||
.encode(text, true)
|
||||
.map_err(|error| anyhow!("cannot tokenize text query: {error}"))?;
|
||||
if encoding.is_empty() {
|
||||
bail!("text query did not produce any tokens");
|
||||
}
|
||||
let input_ids = Tensor::new(encoding.get_ids(), &self.device)?.unsqueeze(0)?;
|
||||
let token_type_ids = Tensor::new(encoding.get_type_ids(), &self.device)?.unsqueeze(0)?;
|
||||
let attention_mask =
|
||||
Tensor::new(encoding.get_attention_mask(), &self.device)?.unsqueeze(0)?;
|
||||
let values = self
|
||||
.model
|
||||
.get_text_features(&input_ids, Some(&token_type_ids), Some(&attention_mask))?
|
||||
.i(0)?
|
||||
.to_vec1::<f32>()?;
|
||||
TextSearchEmbedding::quantize(&values, "text-search text model")
|
||||
}
|
||||
}
|
||||
|
||||
fn load_tokenizer(vocabulary_path: &Path) -> Result<Tokenizer> {
|
||||
let vocabulary = vocabulary_path.to_str().with_context(|| {
|
||||
format!(
|
||||
"vocabulary path is not UTF-8: {}",
|
||||
vocabulary_path.display()
|
||||
)
|
||||
})?;
|
||||
let model = WordPiece::from_file(vocabulary)
|
||||
.unk_token("[UNK]".to_owned())
|
||||
.build()
|
||||
.map_err(|error| anyhow!("cannot load Chinese-CLIP vocabulary: {error}"))?;
|
||||
let mut tokenizer = Tokenizer::new(model);
|
||||
let cls_id = tokenizer
|
||||
.token_to_id("[CLS]")
|
||||
.context("Chinese-CLIP vocabulary is missing [CLS]")?;
|
||||
let sep_id = tokenizer
|
||||
.token_to_id("[SEP]")
|
||||
.context("Chinese-CLIP vocabulary is missing [SEP]")?;
|
||||
tokenizer.with_normalizer(Some(BertNormalizer::default()));
|
||||
tokenizer.with_pre_tokenizer(Some(BertPreTokenizer));
|
||||
tokenizer.with_post_processor(Some(BertProcessing::new(
|
||||
("[SEP]".to_owned(), sep_id),
|
||||
("[CLS]".to_owned(), cls_id),
|
||||
)));
|
||||
tokenizer
|
||||
.with_truncation(Some(TruncationParams {
|
||||
max_length: TEXT_CONTEXT_LENGTH,
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|error| anyhow!("cannot configure Chinese-CLIP tokenizer: {error}"))?;
|
||||
Ok(tokenizer)
|
||||
}
|
||||
|
||||
fn preprocess_thumbnail(path: &Path) -> Result<Tensor> {
|
||||
let image = ImageReader::open(path)
|
||||
.with_context(|| format!("cannot open thumbnail {}", path.display()))?
|
||||
.with_guessed_format()?
|
||||
.decode()
|
||||
.with_context(|| format!("cannot decode thumbnail {}", path.display()))?;
|
||||
preprocess_image(&image)
|
||||
}
|
||||
|
||||
fn preprocess_image(image: &DynamicImage) -> Result<Tensor> {
|
||||
let (width, height) = image.dimensions();
|
||||
if width == 0 || height == 0 {
|
||||
bail!("cannot embed an empty image");
|
||||
}
|
||||
// This checkpoint resizes directly to 224x224 without a centre crop.
|
||||
let pixels = image
|
||||
.resize_exact(MODEL_EDGE, MODEL_EDGE, FilterType::CatmullRom)
|
||||
.to_rgb8();
|
||||
const MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73];
|
||||
const STANDARD_DEVIATION: [f32; 3] = [0.268_629_54, 0.261_302_6, 0.275_777_1];
|
||||
let mut values = Vec::with_capacity((MODEL_EDGE * MODEL_EDGE * 3) as usize);
|
||||
for channel in 0..3 {
|
||||
values.extend(pixels.pixels().map(|pixel| {
|
||||
(f32::from(pixel.0[channel]) / 255.0 - MEAN[channel]) / STANDARD_DEVIATION[channel]
|
||||
}));
|
||||
}
|
||||
Ok(Tensor::from_vec(
|
||||
values,
|
||||
(1, 3, MODEL_EDGE as usize, MODEL_EDGE as usize),
|
||||
&Device::Cpu,
|
||||
)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
use crate::text_search::text_image_similarity;
|
||||
|
||||
#[test]
|
||||
fn tokenizer_uses_chinese_bert_special_tokens() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("vocab.txt");
|
||||
let mut vocabulary = vec!["[PAD]".to_owned()];
|
||||
vocabulary.extend((0..99).map(|index| format!("[unused{index}]")));
|
||||
vocabulary.extend(
|
||||
["[UNK]", "[CLS]", "[SEP]", "[MASK]", "海", "边", "日", "落"].map(str::to_owned),
|
||||
);
|
||||
fs::write(&path, format!("{}\n", vocabulary.join("\n"))).unwrap();
|
||||
|
||||
let encoding = load_tokenizer(&path)
|
||||
.unwrap()
|
||||
.encode("海边日落", true)
|
||||
.unwrap();
|
||||
assert_eq!(encoding.get_ids().first(), Some(&101));
|
||||
assert_eq!(encoding.get_ids().last(), Some(&102));
|
||||
assert!(encoding.len() >= 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprocessing_matches_official_chinese_clip_shape() {
|
||||
let image = DynamicImage::ImageRgb8(image::RgbImage::from_fn(400, 200, |x, y| {
|
||||
image::Rgb([(x % 255) as u8, (y % 255) as u8, 128])
|
||||
}));
|
||||
let tensor = preprocess_image(&image).unwrap();
|
||||
assert_eq!(tensor.dims(), &[1, 3, 224, 224]);
|
||||
assert!(tensor.flatten_all().unwrap().to_vec1::<f32>().unwrap()[0].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_model_runs_when_supplied_by_the_test_environment() {
|
||||
let (Some(model_path), Some(vocabulary_path)) = (
|
||||
std::env::var_os("PIXHELF_CHINESE_CLIP_MODEL"),
|
||||
std::env::var_os("PIXHELF_CHINESE_CLIP_VOCAB"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let red = temp.path().join("red.png");
|
||||
let blue = temp.path().join("blue.png");
|
||||
image::RgbImage::from_pixel(224, 224, image::Rgb([235, 30, 25]))
|
||||
.save(&red)
|
||||
.unwrap();
|
||||
image::RgbImage::from_pixel(224, 224, image::Rgb([25, 40, 235]))
|
||||
.save(&blue)
|
||||
.unwrap();
|
||||
|
||||
let mut model =
|
||||
ChineseClip::load(Path::new(&model_path), Path::new(&vocabulary_path)).unwrap();
|
||||
let query = model.embed_text("一张红色的图片").unwrap();
|
||||
let red = model.embed_thumbnail(&red).unwrap();
|
||||
let blue = model.embed_thumbnail(&blue).unwrap();
|
||||
assert!(
|
||||
text_image_similarity(&query, &red) > text_image_similarity(&query, &blue),
|
||||
"red query should rank the red image first"
|
||||
);
|
||||
}
|
||||
}
|
||||
+96
-10
@@ -54,7 +54,7 @@ pub struct ThumbnailManager {
|
||||
queue: JobQueue,
|
||||
initial_ready: AtomicBool,
|
||||
similarity_warmup_running: AtomicBool,
|
||||
text_search: Option<Arc<TextSearchIndex>>,
|
||||
text_search: RwLock<Option<Arc<TextSearchIndex>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -132,10 +132,48 @@ impl ThumbnailManager {
|
||||
queue: JobQueue::default(),
|
||||
initial_ready: AtomicBool::new(false),
|
||||
similarity_warmup_running: AtomicBool::new(false),
|
||||
text_search,
|
||||
text_search: RwLock::new(text_search),
|
||||
}))
|
||||
}
|
||||
|
||||
fn text_search(&self) -> Option<Arc<TextSearchIndex>> {
|
||||
read_lock(&self.text_search).clone()
|
||||
}
|
||||
|
||||
/// Attach an automatically downloaded search model without restarting the
|
||||
/// web service, then backfill every thumbnail that is already available.
|
||||
pub fn enable_text_search(&self, text_search: Arc<TextSearchIndex>) -> bool {
|
||||
{
|
||||
let mut current = write_lock(&self.text_search);
|
||||
if current.is_some() {
|
||||
return false;
|
||||
}
|
||||
*current = Some(Arc::clone(&text_search));
|
||||
}
|
||||
|
||||
// The model is visible before this snapshot. A thumbnail completed
|
||||
// after that point enqueues itself in the regular worker path, while a
|
||||
// thumbnail completed before it is included below.
|
||||
let (ids, ready) = {
|
||||
let entries = read_lock(&self.entries);
|
||||
let ids = entries.keys().cloned().collect::<Vec<_>>();
|
||||
let ready = entries
|
||||
.values()
|
||||
.filter(|entry| matches!(*mutex_lock(&entry.state), ThumbState::Ready))
|
||||
.filter(|entry| valid_cache_file(&entry.cache_path))
|
||||
.map(|entry| (entry.record.id.clone(), entry.cache_path.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
(ids, ready)
|
||||
};
|
||||
let current_ids = ids.iter().map(String::as_str).collect::<HashSet<_>>();
|
||||
text_search.reconcile(¤t_ids);
|
||||
for (id, thumbnail) in ready {
|
||||
text_search.enqueue(&id, &thumbnail, false);
|
||||
}
|
||||
text_search.start_worker();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn reconcile(&self, records: &[Arc<ImageRecord>]) {
|
||||
let current_ids: HashSet<&str> = records.iter().map(|record| record.id.as_str()).collect();
|
||||
let mut missing = Vec::new();
|
||||
@@ -180,7 +218,7 @@ impl ThumbnailManager {
|
||||
for id in missing {
|
||||
self.queue.push_background(id);
|
||||
}
|
||||
if let Some(text_search) = &self.text_search {
|
||||
if let Some(text_search) = self.text_search() {
|
||||
text_search.reconcile(¤t_ids);
|
||||
let ready = {
|
||||
let entries = read_lock(&self.entries);
|
||||
@@ -304,8 +342,8 @@ impl ThumbnailManager {
|
||||
initial_batch_ready: self.initial_ready.load(Ordering::Acquire),
|
||||
background_complete: ready + failed == total && processing == 0,
|
||||
text_search: self
|
||||
.text_search
|
||||
.as_ref()
|
||||
.text_search()
|
||||
.as_deref()
|
||||
.map_or_else(TextSearchStatus::disabled, |index| index.status()),
|
||||
}
|
||||
}
|
||||
@@ -314,19 +352,19 @@ impl ThumbnailManager {
|
||||
&self,
|
||||
query: &str,
|
||||
) -> Result<Option<Arc<TextSearchEmbedding>>> {
|
||||
let Some(index) = &self.text_search else {
|
||||
let Some(index) = self.text_search() else {
|
||||
return Ok(None);
|
||||
};
|
||||
index.embed_query(query).await.map(Some)
|
||||
}
|
||||
|
||||
pub fn text_search_embedding(&self, id: &str) -> Option<Arc<TextSearchEmbedding>> {
|
||||
self.text_search.as_ref()?.embedding(id)
|
||||
self.text_search()?.embedding(id)
|
||||
}
|
||||
|
||||
pub fn text_search_cache_token(&self) -> String {
|
||||
self.text_search
|
||||
.as_ref()
|
||||
self.text_search()
|
||||
.as_deref()
|
||||
.map_or_else(|| "off".to_owned(), |index| index.cache_token())
|
||||
}
|
||||
|
||||
@@ -447,7 +485,7 @@ impl ThumbnailManager {
|
||||
}
|
||||
};
|
||||
if ready {
|
||||
if let Some(text_search) = &self.text_search {
|
||||
if let Some(text_search) = self.text_search() {
|
||||
text_search.enqueue(&id, &entry.cache_path, false);
|
||||
}
|
||||
entry.notify.notify_waiters();
|
||||
@@ -656,6 +694,18 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::gallery::scan_gallery;
|
||||
|
||||
struct ConstantTextSearchModel;
|
||||
|
||||
impl crate::text_search::TextImageModel for ConstantTextSearchModel {
|
||||
fn embed_thumbnail(&mut self, _thumbnail: &Path) -> Result<TextSearchEmbedding> {
|
||||
Ok(TextSearchEmbedding::for_test([(0, 100)]))
|
||||
}
|
||||
|
||||
fn embed_text(&mut self, _text: &str) -> Result<TextSearchEmbedding> {
|
||||
Ok(TextSearchEmbedding::for_test([(0, 100)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_one_bounded_webp_thumbnail() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -760,6 +810,42 @@ mod tests {
|
||||
.expect("similarity descriptor warmup timed out");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downloaded_text_search_attaches_to_existing_thumbnails() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
fs::create_dir(&gallery).unwrap();
|
||||
image::RgbImage::new(20, 10)
|
||||
.save(gallery.join("image.png"))
|
||||
.unwrap();
|
||||
let gallery = scan_gallery(&gallery, None).unwrap();
|
||||
let manager = ThumbnailManager::new(temp.path().join("cache")).unwrap();
|
||||
manager.reconcile(&gallery.images);
|
||||
manager.start_workers(1);
|
||||
let id = gallery.images[0].id.clone();
|
||||
manager.ensure_ready(&id).await.unwrap();
|
||||
assert!(!manager.status().text_search.enabled);
|
||||
|
||||
let text_search = TextSearchIndex::with_test_model(
|
||||
Box::new(ConstantTextSearchModel),
|
||||
[17; crate::support::embedding::FINGERPRINT_BYTES],
|
||||
);
|
||||
assert!(manager.enable_text_search(text_search));
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let status = manager.status().text_search;
|
||||
if status.background_complete && status.ready == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("dynamically attached text-search index timed out");
|
||||
assert!(manager.text_search_embedding(&id).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_source_requires_a_fresh_gallery_record() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
+160
@@ -24,6 +24,7 @@ use tracing::error;
|
||||
|
||||
use crate::{
|
||||
gallery::{Album, GalleryIndex, ImageRecord},
|
||||
photo_details::PhotoDetails,
|
||||
similarity::{
|
||||
DiversityFingerprint, MIN_SIMILARITY_SCORE, redundancy_score, signature_for_thumbnail,
|
||||
similarity_score,
|
||||
@@ -56,6 +57,7 @@ const TEXT_SEARCH_RANKING_CACHE_LIMIT: usize = 16;
|
||||
const TEXT_SEARCH_RESULT_LIMIT: usize = 600;
|
||||
const FILENAME_MATCH_BOOST: f32 = 3.0;
|
||||
const MAX_QUERY_VALUE_BYTES: usize = 4096;
|
||||
const PHOTO_DETAILS_CACHE_LIMIT: usize = 64;
|
||||
|
||||
static SIMILARITY_LIMIT: OnceLock<Arc<Semaphore>> = OnceLock::new();
|
||||
type SimilarityRanking = Arc<Vec<Arc<ImageRecord>>>;
|
||||
@@ -64,6 +66,8 @@ static SIMILARITY_RANKING_CACHE: OnceLock<SimilarityRankingCache> = OnceLock::ne
|
||||
type TextSearchRanking = Arc<Vec<Arc<ImageRecord>>>;
|
||||
type TextSearchRankingCache = Mutex<BoundedCache<TextSearchRanking>>;
|
||||
static TEXT_SEARCH_RANKING_CACHE: OnceLock<TextSearchRankingCache> = OnceLock::new();
|
||||
type PhotoDetailsCache = Mutex<BoundedCache<Arc<PhotoDetails>>>;
|
||||
static PHOTO_DETAILS_CACHE: OnceLock<PhotoDetailsCache> = OnceLock::new();
|
||||
|
||||
fn similarity_limit() -> Arc<Semaphore> {
|
||||
Arc::clone(SIMILARITY_LIMIT.get_or_init(|| Arc::new(Semaphore::new(1))))
|
||||
@@ -95,6 +99,18 @@ fn cache_text_search_ranking(key: String, ranking: TextSearchRanking) {
|
||||
mutex_lock(text_search_ranking_cache()).insert(key, ranking);
|
||||
}
|
||||
|
||||
fn photo_details_cache() -> &'static PhotoDetailsCache {
|
||||
PHOTO_DETAILS_CACHE.get_or_init(|| Mutex::new(BoundedCache::new(PHOTO_DETAILS_CACHE_LIMIT)))
|
||||
}
|
||||
|
||||
fn cached_photo_details(key: &str) -> Option<Arc<PhotoDetails>> {
|
||||
mutex_lock(photo_details_cache()).get_cloned(key)
|
||||
}
|
||||
|
||||
fn cache_photo_details(key: String, details: Arc<PhotoDetails>) {
|
||||
mutex_lock(photo_details_cache()).insert(key, details);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub index: Arc<RwLock<Arc<GalleryIndex>>>,
|
||||
@@ -250,6 +266,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/gallery", get(gallery_summary))
|
||||
.route("/api/images", get(images))
|
||||
.route("/api/images/{id}/details", get(image_details))
|
||||
.route("/api/images/{id}/similar", get(similar_images))
|
||||
.route("/api/status", get(thumbnail_status))
|
||||
.route("/api/images/{id}/thumbnail", get(thumbnail))
|
||||
@@ -833,6 +850,81 @@ async fn thumbnail_status(State(state): State<AppState>) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
async fn image_details(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let image = {
|
||||
let index = state.index.read().await;
|
||||
let Some(image) = index.image(&id).cloned() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
image
|
||||
};
|
||||
let metadata = match tokio::fs::metadata(&image.path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
Err(error) => {
|
||||
error!(path = %image.path.display(), %error, "cannot inspect image details source");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
if !image.matches_metadata(&metadata) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
let etag = format!("\"details-v1-{id}\"");
|
||||
if is_not_modified(&headers, &etag) {
|
||||
return not_modified(&etag, "private, max-age=31536000, immutable");
|
||||
}
|
||||
|
||||
let cache_key = format!("{}:{}", image.path.display(), image.id);
|
||||
if let Some(details) = cached_photo_details(&cache_key) {
|
||||
return with_cache_headers(
|
||||
Json(details.as_ref()).into_response(),
|
||||
&etag,
|
||||
"private, max-age=31536000, immutable",
|
||||
);
|
||||
}
|
||||
|
||||
let thumbnail = match state.thumbnails.ensure_ready(&id).await {
|
||||
Ok(path) => path,
|
||||
Err(ThumbnailError::NotFound) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(error) => {
|
||||
error!(%id, %error, "cannot prepare histogram source");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
let details = match tokio::task::spawn_blocking({
|
||||
let image = Arc::clone(&image);
|
||||
move || PhotoDetails::read(&image, &thumbnail)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(details)) => Arc::new(details),
|
||||
Ok(Err(error)) => {
|
||||
if image.ensure_source_is_current().is_err() {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
error!(%id, %error, "cannot read image details");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%id, %error, "image details task stopped");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
cache_photo_details(cache_key, Arc::clone(&details));
|
||||
with_cache_headers(
|
||||
Json(details.as_ref()).into_response(),
|
||||
&etag,
|
||||
"private, max-age=31536000, immutable",
|
||||
)
|
||||
}
|
||||
|
||||
async fn thumbnail(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
@@ -1310,6 +1402,10 @@ mod tests {
|
||||
request("/api/images/missing/original").await.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
request("/api/images/missing/details").await.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
request("/api/images/missing/similar").await.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
@@ -1551,6 +1647,70 @@ mod tests {
|
||||
assert!(!body.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_details_expose_file_data_and_histogram() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let gallery = temp.path().join("gallery");
|
||||
std::fs::create_dir(&gallery).unwrap();
|
||||
image::RgbImage::from_pixel(20, 10, image::Rgb([180, 90, 30]))
|
||||
.save(gallery.join("image.png"))
|
||||
.unwrap();
|
||||
let index = crate::gallery::scan_gallery(&gallery, None).unwrap();
|
||||
let id = index.images[0].id.clone();
|
||||
let expected_size = index.images[0].size;
|
||||
let thumbnails = ThumbnailManager::new(temp.path().join("cache")).unwrap();
|
||||
thumbnails.reconcile(&index.images);
|
||||
thumbnails.start_workers(1);
|
||||
let app = router(AppState {
|
||||
index: Arc::new(RwLock::new(Arc::new(index))),
|
||||
thumbnails,
|
||||
});
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/images/{id}/details"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"private, max-age=31536000, immutable"
|
||||
);
|
||||
let etag = response.headers()[header::ETAG].clone();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let details: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(details["fileSize"], expected_size);
|
||||
assert!(details["modifiedMs"].as_u64().unwrap() > 0);
|
||||
assert!(details["exif"].as_array().unwrap().is_empty());
|
||||
for channel in ["red", "green", "blue", "luminance"] {
|
||||
let bins = details["histogram"][channel].as_array().unwrap();
|
||||
assert_eq!(bins.len(), 256);
|
||||
assert_eq!(
|
||||
bins.iter().map(|bin| bin.as_u64().unwrap()).sum::<u64>(),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
let cached = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/images/{id}/details"))
|
||||
.header(header::IF_NONE_MATCH, etag)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_image_ids_do_not_serve_replaced_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in new issue
Block a user