From 48b7cb17df3db99a4ae3b5de6eb60183fb13d28b Mon Sep 17 00:00:00 2001 From: adminroot Date: Tue, 1 Sep 2026 21:51:22 +0000 Subject: [PATCH] Release v0.2.2 --- .forgejo/workflows/release.yaml | 97 +++- Cargo.lock | 806 ++++++++++++++++++++++++++++- Cargo.toml | 7 +- Dockerfile | 16 +- README.md | 52 +- compose.yml | 3 - frontend/index.html | 5 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/scripts/visual-check.mjs | 27 +- frontend/src/App.tsx | 164 +++++- frontend/src/GalleryNavigation.tsx | 2 + frontend/src/ImageViewer.tsx | 418 +++++++-------- frontend/src/PhotoHistogram.tsx | 75 +++ frontend/src/PhotoInformation.css | 445 ++++++++++++++++ frontend/src/PhotoInformation.tsx | 318 ++++++++++++ frontend/src/SimilarImages.tsx | 52 +- frontend/src/ToolbarPopover.tsx | 1 + frontend/src/api.ts | 35 ++ frontend/src/format.ts | 19 + frontend/src/main.tsx | 1 + frontend/src/styles.css | 420 ++++++--------- frontend/src/types.ts | 19 + src/config.rs | 210 +++++++- src/main.rs | 77 ++- src/photo_details.rs | 448 ++++++++++++++++ src/text_search.rs | 317 ++++-------- src/text_search/download.rs | 496 ++++++++++++++++++ src/text_search/model.rs | 334 ++++++++++++ src/thumbs.rs | 106 +++- src/web.rs | 160 ++++++ 31 files changed, 4327 insertions(+), 809 deletions(-) create mode 100644 frontend/src/PhotoHistogram.tsx create mode 100644 frontend/src/PhotoInformation.css create mode 100644 frontend/src/PhotoInformation.tsx create mode 100644 src/photo_details.rs create mode 100644 src/text_search/download.rs create mode 100644 src/text_search/model.rs 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 ? "换一组" : "随机探索"} >