diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml
index c620304..d389125 100644
--- a/.forgejo/workflows/release.yaml
+++ b/.forgejo/workflows/release.yaml
@@ -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)"
diff --git a/Cargo.lock b/Cargo.lock
index 53c8dd7..4f4d4bf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -146,6 +146,12 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -177,7 +183,16 @@ dependencies = [
"cc",
"cfg-if",
"constant_time_eq",
- "cpufeatures",
+ "cpufeatures 0.3.0",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
]
[[package]]
@@ -201,6 +216,12 @@ dependencies = [
"alloc-stdlib",
]
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
[[package]]
name = "bytemuck"
version = "1.25.2"
@@ -253,7 +274,7 @@ dependencies = [
"memmap2",
"num-traits",
"num_cpus",
- "rand",
+ "rand 0.9.5",
"rand_distr",
"rayon",
"safetensors",
@@ -289,7 +310,7 @@ dependencies = [
"candle-nn",
"fancy-regex 0.17.0",
"num-traits",
- "rand",
+ "rand 0.9.5",
"rayon",
"serde",
"serde_json",
@@ -324,6 +345,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "compact_str"
version = "0.9.1"
@@ -363,6 +401,15 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -412,6 +459,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
[[package]]
name = "darling"
version = "0.20.11"
@@ -487,6 +544,27 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
[[package]]
name = "dyn-stack"
version = "0.13.2"
@@ -534,7 +612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -604,7 +682,7 @@ checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5"
dependencies = [
"half",
"num-traits",
- "rand",
+ "rand 0.9.5",
"rand_distr",
]
@@ -787,6 +865,29 @@ dependencies = [
"seq-macro",
]
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
[[package]]
name = "getrandom"
version = "0.3.4"
@@ -806,8 +907,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
]
[[package]]
@@ -826,7 +930,7 @@ dependencies = [
"cfg-if",
"crunchy",
"num-traits",
- "rand",
+ "rand 0.9.5",
"rand_distr",
"zerocopy",
]
@@ -931,6 +1035,23 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
]
[[package]]
@@ -939,13 +1060,104 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
+ "base64 0.22.1",
"bytes",
+ "futures-channel",
+ "futures-util",
"http",
"http-body",
"hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
"pin-project-lite",
+ "socket2",
"tokio",
"tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
]
[[package]]
@@ -954,6 +1166,27 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
[[package]]
name = "image"
version = "0.25.10"
@@ -990,6 +1223,12 @@ dependencies = [
"hashbrown 0.17.1",
]
+[[package]]
+name = "ipnet"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
+
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1015,6 +1254,26 @@ dependencies = [
"libc",
]
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kamadak-exif"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837"
+dependencies = [
+ "mutate_once",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -1049,12 +1308,24 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
[[package]]
name = "macro_rules_attribute"
version = "0.2.3"
@@ -1142,7 +1413,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1177,6 +1448,12 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "mutate_once"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
+
[[package]]
name = "natord"
version = "1.0.9"
@@ -1199,7 +1476,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1264,7 +1541,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pixhelf"
-version = "0.2.1"
+version = "0.2.2"
dependencies = [
"anyhow",
"axum",
@@ -1273,9 +1550,12 @@ dependencies = [
"candle-nn",
"candle-transformers",
"image",
+ "kamadak-exif",
"natord",
+ "reqwest",
"serde",
"serde_json",
+ "sha2",
"tempfile",
"tokenizers",
"tokio",
@@ -1300,6 +1580,15 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -1353,6 +1642,62 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.20",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.2",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.20",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "quote"
version = "1.0.47"
@@ -1381,7 +1726,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
- "rand_core",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
]
[[package]]
@@ -1391,7 +1747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "rand_core 0.9.5",
]
[[package]]
@@ -1403,6 +1759,12 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
[[package]]
name = "rand_distr"
version = "0.5.1"
@@ -1410,7 +1772,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
dependencies = [
"num-traits",
- "rand",
+ "rand 0.9.5",
+]
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
]
[[package]]
@@ -1488,6 +1859,64 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
[[package]]
name = "rustix"
version = "1.1.4"
@@ -1498,7 +1927,42 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
]
[[package]]
@@ -1614,6 +2078,17 @@ dependencies = [
"serde",
]
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -1664,7 +2139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1673,7 +2148,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
dependencies = [
- "base64",
+ "base64 0.13.1",
"nom",
"serde",
"unicode-segmentation",
@@ -1697,6 +2172,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
[[package]]
name = "syn"
version = "2.0.119"
@@ -1724,6 +2205,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
[[package]]
name = "synstructure"
@@ -1760,7 +2244,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1812,6 +2296,31 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
[[package]]
name = "tokenizers"
version = "0.22.2"
@@ -1831,7 +2340,7 @@ dependencies = [
"macro_rules_attribute",
"monostate",
"paste",
- "rand",
+ "rand 0.9.5",
"rayon",
"rayon-cond",
"regex",
@@ -1858,7 +2367,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1872,6 +2381,16 @@ dependencies = [
"syn 3.0.3",
]
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
[[package]]
name = "tokio-util"
version = "0.7.19"
@@ -1923,9 +2442,11 @@ dependencies = [
"pin-project-lite",
"tokio",
"tokio-util",
+ "tower",
"tower-layer",
"tower-service",
"tracing",
+ "url",
]
[[package]]
@@ -2002,12 +2523,24 @@ dependencies = [
"tracing-log",
]
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
[[package]]
name = "unicase"
version = "2.9.0"
@@ -2041,6 +2574,30 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
[[package]]
name = "valuable"
version = "0.1.1"
@@ -2063,6 +2620,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -2078,6 +2644,81 @@ dependencies = [
"wit-bindgen",
]
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "webp"
version = "0.3.1"
@@ -2088,13 +2729,22 @@ dependencies = [
"libwebp-sys",
]
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2103,6 +2753,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -2112,12 +2771,82 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
[[package]]
name = "yoke"
version = "0.8.3"
@@ -2182,6 +2911,45 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
[[package]]
name = "zip"
version = "7.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index 860b461..f6115d5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/Dockerfile b/Dockerfile
index 4ddbe92..d80ac9c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index a4cfbf7..d63300c 100644
--- a/README.md
+++ b/README.md
@@ -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 信息。数据仅在打开查看器时按需读取并缓存在内存中;直方图复用已有缩略图,不会在图库扫描阶段批量解码原图。
diff --git a/compose.yml b/compose.yml
index b8f0c26..4834945 100644
--- a/compose.yml
+++ b/compose.yml
@@ -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:
diff --git a/frontend/index.html b/frontend/index.html
index 12beca6..91dd0ac 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -7,7 +7,10 @@
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
/>
-
+
Pixhelf
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 36fec1e..fd2b3e3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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"
},
diff --git a/frontend/package.json b/frontend/package.json
index 7347ef2..0c68d73 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/frontend/scripts/visual-check.mjs b/frontend/scripts/visual-check.mjs
index 701911b..da443cf 100644
--- a/frontend/scripts/visual-check.mjs
+++ b/frontend/scripts/visual-check.mjs
@@ -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 ||
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 67fa06a..bdad784 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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)[VIEWER_HISTORY_STATE_KEY];
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const entry = value as Record;
+ 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;
+ 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 {
+ const current = window.history.state;
+ const preserved = current && typeof current === "object" && !Array.isArray(current)
+ ? current as Record
+ : {};
+ 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(null);
const viewerReturnSequenceRef = useRef(0);
const viewerReturnFlightRef = useRef(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}
/>
)}
diff --git a/frontend/src/GalleryNavigation.tsx b/frontend/src/GalleryNavigation.tsx
index 0fbfe4a..b68fced 100644
--- a/frontend/src/GalleryNavigation.tsx
+++ b/frontend/src/GalleryNavigation.tsx
@@ -113,6 +113,7 @@ export function Header({
aria-label={exploreActive ? "换一组图片" : "随机探索"}
aria-pressed={exploreActive}
aria-busy={exploreLoading}
+ data-state={exploreActive ? "active" : "idle"}
title={exploreActive ? "换一组" : "随机探索"}
>
diff --git a/frontend/src/ImageViewer.tsx b/frontend/src/ImageViewer.tsx
index 1a3a888..ecee36a 100644
--- a/frontend/src/ImageViewer.tsx
+++ b/frontend/src/ImageViewer.tsx
@@ -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("image");
+ const viewerScrollSnapshotRef = useRef({
+ 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());
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) => (
+
+ {details && (
+
+ )}
+
+
+
+
+ {fullscreenAvailable && (
+
+ )}
+
+
+ );
const viewer = (
图片详情
{displayPosition} / {total}
-
-
-
-
+ {renderActionSuite(true)}
-
-
-
-
{orientation} · {fileExtension}
-
{image.name}
-
{image.width.toLocaleString()} × {image.height.toLocaleString()} 像素
-
-
-
-
- 文件信息
-
-
-
- 分辨率
- - {image.width.toLocaleString()} × {image.height.toLocaleString()}
-
-
-
- 像素
- - {megapixels >= 1 ? `${megapixels.toFixed(1)} MP` : `${Math.round(megapixels * 1000)} KP`}
-
-
-
- 格式
- - {fileExtension}
-
-
-
- 图库位置
- - {displayPosition} / {total}
-
-
-
-
-
-
-
- 下载原图
- 保留原始尺寸与格式
-
-
-
+
-
-
-
相似图片
-
在图库中查找构图、色彩和纹理相近的图片
-
-
-
+ ref={similarSectionRef}
+ className={`viewer-similar-section${similarActive ? "" : " is-idle"}`}
+ aria-labelledby={similarActive ? "viewer-similar-title" : undefined}
+ data-similar-active={similarActive}
+ >
+ {similarActive && (
+ <>
+
- {!similarActive ? (
-
- ) : similarLoading && !similarImages.length ? (
-
- ) : similarError && !similarImages.length ? (
-
- {similarError}
-
-
- ) : similarImages.length ? (
-
- ) : (
-
-
- 暂时没有找到相似图片
-
- )}
+ {similarLoading && !similarImages.length ? (
+
+ ) : similarError && !similarImages.length ? (
+
+ {similarError}
+
+
+ ) : similarImages.length ? (
+
+ ) : (
+
+
+ 暂时没有找到相似图片
+
+ )}
+ >
+ )}
diff --git a/frontend/src/PhotoHistogram.tsx b/frontend/src/PhotoHistogram.tsx
new file mode 100644
index 0000000..4b5f08e
--- /dev/null
+++ b/frontend/src/PhotoHistogram.tsx
@@ -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 (
+
+ {CHANNELS.map(({ key, label }) => (
+ {label}
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+ 暗部
+ 中间调
+ 高光
+
+
+ );
+});
diff --git a/frontend/src/PhotoInformation.css b/frontend/src/PhotoInformation.css
new file mode 100644
index 0000000..52340ff
--- /dev/null
+++ b/frontend/src/PhotoInformation.css
@@ -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; }
+}
diff --git a/frontend/src/PhotoInformation.tsx b/frontend/src/PhotoInformation.tsx
new file mode 100644
index 0000000..51c80fe
--- /dev/null
+++ b/frontend/src/PhotoInformation.tsx
@@ -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 = 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 (
+
+ {fields.map((field) => (
+
+
- {field.label}
+ -
+ {field.value ?? }
+
+
+ ))}
+
+ );
+}
+
+function InformationSection({
+ title,
+ fields,
+ listClassName,
+}: {
+ title: string;
+ fields: InformationField[];
+ listClassName?: string;
+}) {
+ return (
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
影调分布
+
+
+ {details ? (
+
+ ) : errorMessage ? (
+
+ ) : (
+
+ )}
+
+
+
+ {shooting.length > 0 && (
+
+ 拍摄参数
+
+ {shooting.map((parameter) => (
+
+
- {parameter.label}
+ - {parameter.value}
+
+ ))}
+
+
+ )}
+
+ {(devices.length > 0 || additional.length > 0) && (
+
+ {devices.length > 0 && (
+
+ )}
+
+ {additional.length > 0 && (
+
+ )}
+
+ )}
+
+ {loading && (
+
+
+
+ )}
+
+ {details && exif.length === 0 && (
+
无拍摄参数
+ )}
+
+ );
+}
+
+function PhotoDataError({ message, onRetry }: { message: string; onRetry: () => void }) {
+ return (
+
+ {message}
+
+
+ );
+}
+
+function usePhotoDetails(imageId: string) {
+ const [attempt, setAttempt] = useState(0);
+ const [state, setState] = useState({
+ 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 (
+
+
+
+
+ {informationStatus(details, loading, errorMessage)}
+
+
+
+
+ );
+});
diff --git a/frontend/src/SimilarImages.tsx b/frontend/src/SimilarImages.tsx
index cc84a81..42875ea 100644
--- a/frontend/src/SimilarImages.tsx
+++ b/frontend/src/SimilarImages.tsx
@@ -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(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(".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 (
<>
)}
{!error && (
-
+
{hasMore ? (
-
+
{loadingMore ? "正在续载相似图片" : "继续下滑自动加载"}
+
) : (
已显示全部 {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 (
-
- {[1, 2, 3, 4, 5, 6].map((item) =>
)}
+
+ {Array.from({ length: itemCount }, (_, index) => )}
);
}
diff --git a/frontend/src/ToolbarPopover.tsx b/frontend/src/ToolbarPopover.tsx
index b60a322..b55aeca 100644
--- a/frontend/src/ToolbarPopover.tsx
+++ b/frontend/src/ToolbarPopover.tsx
@@ -75,6 +75,7 @@ export function ToolbarPopover({
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={id}
+ data-state={open ? "open" : "closed"}
title={triggerLabel}
>
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 18b637e..6310949 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -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;
type JsonValidator = (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 {
+ return getJson(
+ `/api/images/${encodeURIComponent(imageId)}/details`,
+ isPhotoDetails,
+ signal,
+ PHOTO_DETAILS_REQUEST_TIMEOUT_MS,
+ );
+}
diff --git a/frontend/src/format.ts b/frontend/src/format.ts
index 5fdf089..db2aceb 100644
--- a/frontend/src/format.ts
+++ b/frontend/src/format.ts
@@ -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`;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index d485e35..b3710f7 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -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");
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 24cb5cc..c1c7cd1 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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); }
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 6e1bb6a..902cac8 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -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;
diff --git a/src/config.rs b/src/config.rs
index dbc724e..17428e5 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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,
+ pub text_search: Option,
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 {
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 {
.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 {
+ 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,
+ cwd: &std::path::Path,
+) -> Result