Merge branch 'develop' into arch/armv7

# Conflicts:
#	internal/ai/classify/model_test.go
#	internal/ai/classify/tensorflow.go
#	internal/ai/face/net.go
#	internal/ai/nsfw/detector.go
#	internal/config/config_ai.go
This commit is contained in:
Michael Mayer
2025-04-14 16:34:10 +02:00
246 changed files with 6621 additions and 1871 deletions

2
.gitignore vendored
View File

@@ -49,6 +49,8 @@ frontend/coverage/
/assets/nasnet
/assets/nsfw
/assets/static/build/
/assets/*net
/assets/vision
/pro
/plus

View File

@@ -1,5 +1,5 @@
# Ubuntu 24.10 (Oracular Oriole)
FROM photoprism/develop:250402-oracular
FROM photoprism/develop:250412-oracular
## Alternative Environments:
# FROM photoprism/develop:armv7 # ARMv7 (32bit)

View File

@@ -152,7 +152,7 @@ clean-build:
tar.gz:
$(info Creating tar.gz archives from the directories in "$(BUILD_PATH)"...)
find "$(BUILD_PATH)" -maxdepth 1 -mindepth 1 -type d -name "photoprism*" -exec tar --exclude='.[^/]*' -C {} -czf {}.tar.gz . \;
pkg: pkg-amd64 pkg-arm64 pkg-armv7
pkg: pkg-amd64 pkg-arm64
pkg-amd64:
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" photoprism/develop:jammy make all install tar.gz
pkg-arm64:
@@ -399,9 +399,11 @@ docker-build:
$(DOCKER_COMPOSE) build --pull
docker-nvidia: docker-nvidia-up
docker-nvidia-up:
docker compose -f compose.nvidia.yaml up
docker compose --profile=qdrant -f compose.nvidia.yaml up
docker-nvidia-down:
docker compose --profile=qdrant -f compose.nvidia.yaml down --remove-orphans
docker-nvidia-build:
docker compose -f compose.nvidia.yaml up
docker compose --profile=qdrant -f compose.nvidia.yaml build
docker-intel: docker-intel-up
docker-intel-up:
docker compose -f compose.intel.yaml up
@@ -422,7 +424,7 @@ docker-develop-bookworm:
docker pull --platform=amd64 debian:bookworm-slim
docker pull --platform=arm64 debian:bookworm-slim
docker pull --platform=arm debian:bookworm-slim
scripts/docker/buildx-multi.sh develop linux/amd64,linux/arm64,linux/arm bookworm /bookworm "-t photoprism/develop:debian"
scripts/docker/buildx-multi.sh develop linux/amd64,linux/arm64 bookworm /bookworm "-t photoprism/develop:debian"
docker-develop-bookworm-slim:
docker pull --platform=amd64 debian:bookworm-slim
docker pull --platform=arm64 debian:bookworm-slim
@@ -431,7 +433,7 @@ docker-develop-bullseye:
docker pull --platform=amd64 golang:1-bullseye
docker pull --platform=arm64 golang:1-bullseye
docker pull --platform=arm golang:1-bullseye
scripts/docker/buildx-multi.sh develop linux/amd64,linux/arm64,linux/arm bullseye /bullseye
scripts/docker/buildx-multi.sh develop linux/amd64,linux/arm64 bullseye /bullseye
docker-develop-bullseye-slim:
docker pull --platform=amd64 debian:bullseye-slim
docker pull --platform=arm64 debian:bullseye-slim
@@ -451,8 +453,7 @@ docker-develop-impish:
docker-develop-jammy:
docker pull --platform=amd64 ubuntu:jammy
docker pull --platform=arm64 ubuntu:jammy
docker pull --platform=arm ubuntu:jammy
scripts/docker/buildx-multi.sh develop linux/amd64,linux/arm64,linux/arm jammy /jammy
scripts/docker/buildx-multi.sh develop linux/amd64,linux/arm64 jammy /jammy
docker-develop-jammy-slim:
docker pull --platform=amd64 ubuntu:jammy
docker pull --platform=arm64 ubuntu:jammy

450
NOTICE
View File

@@ -9,7 +9,7 @@ The following 3rd-party software packages may be used by or distributed with
PhotoPrism. Any information relevant to third-party vendors listed below are
collected using common, reasonable means.
Date generated: 2025-03-27
Date generated: 2025-04-14
================================================================================
@@ -1039,8 +1039,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/gin-contrib/gzip
Version: v1.2.2
License: MIT (https://github.com/gin-contrib/gzip/blob/v1.2.2/LICENSE)
Version: v1.2.3
License: MIT (https://github.com/gin-contrib/gzip/blob/v1.2.3/LICENSE)
MIT License
@@ -1067,8 +1067,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/gin-contrib/sse
Version: v1.0.0
License: MIT (https://github.com/gin-contrib/sse/blob/v1.0.0/LICENSE)
Version: v1.1.0
License: MIT (https://github.com/gin-contrib/sse/blob/v1.1.0/LICENSE)
The MIT License (MIT)
@@ -1879,8 +1879,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/go-playground/validator/v10
Version: v10.25.0
License: MIT (https://github.com/go-playground/validator/blob/v10.25.0/LICENSE)
Version: v10.26.0
License: MIT (https://github.com/go-playground/validator/blob/v10.26.0/LICENSE)
The MIT License (MIT)
@@ -2316,8 +2316,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/golang/geo
Version: v0.0.0-20250324010448-bc23e40121c4
License: Apache-2.0 (https://github.com/golang/geo/blob/bc23e40121c4/LICENSE)
Version: v0.0.0-20250411042641-97e19c1a7ce7
License: Apache-2.0 (https://github.com/golang/geo/blob/97e19c1a7ce7/LICENSE)
Apache License
@@ -2525,8 +2525,8 @@ License: Apache-2.0 (https://github.com/golang/geo/blob/bc23e40121c4/LICENSE)
--------------------------------------------------------------------------------
Package: github.com/google/open-location-code/go
Version: v0.0.0-20250307090349-1695db3c3b15
License: Apache-2.0 (https://github.com/google/open-location-code/blob/1695db3c3b15/go/LICENSE)
Version: v0.0.0-20250413133937-894dfd253334
License: Apache-2.0 (https://github.com/google/open-location-code/blob/894dfd253334/go/LICENSE)
Apache License
@@ -4432,8 +4432,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: github.com/pelletier/go-toml/v2
Version: v2.2.3
License: MIT (https://github.com/pelletier/go-toml/blob/v2.2.3/LICENSE)
Version: v2.2.4
License: MIT (https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)
The MIT License (MIT)
@@ -4670,8 +4670,8 @@ License: Apache-2.0 (https://github.com/pquerna/otp/blob/v1.4.0/LICENSE)
--------------------------------------------------------------------------------
Package: github.com/prometheus/client_golang/prometheus
Version: v1.21.1
License: Apache-2.0 (https://github.com/prometheus/client_golang/blob/v1.21.1/LICENSE)
Version: v1.22.0
License: Apache-2.0 (https://github.com/prometheus/client_golang/blob/v1.22.0/LICENSE)
Apache License
Version 2.0, January 2004
@@ -5663,11 +5663,204 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/tensorflow/tensorflow/tensorflow/go
Version: v1.15.2
License: Apache-2.0 (https://github.com/tensorflow/tensorflow/blob/v1.15.2/LICENSE)
Package: github.com/tidwall/gjson
Version: v1.18.0
License: MIT (https://github.com/tidwall/gjson/blob/v1.18.0/LICENSE)
Copyright 2019 The TensorFlow Authors. All rights reserved.
The MIT License (MIT)
Copyright (c) 2016 Josh Baker
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/tidwall/match
Version: v1.1.1
License: MIT (https://github.com/tidwall/match/blob/v1.1.1/LICENSE)
The MIT License (MIT)
Copyright (c) 2016 Josh Baker
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/tidwall/pretty
Version: v1.2.1
License: MIT (https://github.com/tidwall/pretty/blob/v1.2.1/LICENSE)
The MIT License (MIT)
Copyright (c) 2017 Josh Baker
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/ugjka/go-tz/v2
Version: v2.2.6
License: MIT (https://github.com/ugjka/go-tz/blob/v2.2.6/LICENSE)
MIT License
Copyright (c) 2017 ugjka <esesmu@protonmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/ugorji/go/codec
Version: v1.2.12
License: MIT (https://github.com/ugorji/go/blob/codec/v1.2.12/codec/LICENSE)
The MIT License (MIT)
Copyright (c) 2012-2020 Ugorji Nwoke.
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/ulule/deepcopier
Version: v0.0.0-20200430083143-45decc6639b6
License: MIT (https://github.com/ulule/deepcopier/blob/45decc6639b6/LICENSE)
The MIT License (MIT)
Copyright (c) 2015 Ulule
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/urfave/cli/v2
Version: v2.27.6
License: MIT (https://github.com/urfave/cli/blob/v2.27.6/LICENSE)
MIT License
Copyright (c) 2022 urfave/cli maintainers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/wamuir/graft/tensorflow
Version: v0.10.0
License: Apache-2.0 (https://github.com/wamuir/graft/blob/v0.10.0/LICENSE)
Apache License
Version 2.0, January 2004
@@ -5873,201 +6066,6 @@ Copyright 2019 The TensorFlow Authors. All rights reserved.
--------------------------------------------------------------------------------
Package: github.com/tidwall/gjson
Version: v1.18.0
License: MIT (https://github.com/tidwall/gjson/blob/v1.18.0/LICENSE)
The MIT License (MIT)
Copyright (c) 2016 Josh Baker
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/tidwall/match
Version: v1.1.1
License: MIT (https://github.com/tidwall/match/blob/v1.1.1/LICENSE)
The MIT License (MIT)
Copyright (c) 2016 Josh Baker
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/tidwall/pretty
Version: v1.2.1
License: MIT (https://github.com/tidwall/pretty/blob/v1.2.1/LICENSE)
The MIT License (MIT)
Copyright (c) 2017 Josh Baker
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/ugjka/go-tz/v2
Version: v2.2.6
License: MIT (https://github.com/ugjka/go-tz/blob/v2.2.6/LICENSE)
MIT License
Copyright (c) 2017 ugjka <esesmu@protonmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/ugorji/go/codec
Version: v1.2.12
License: MIT (https://github.com/ugorji/go/blob/codec/v1.2.12/codec/LICENSE)
The MIT License (MIT)
Copyright (c) 2012-2020 Ugorji Nwoke.
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/ulule/deepcopier
Version: v0.0.0-20200430083143-45decc6639b6
License: MIT (https://github.com/ulule/deepcopier/blob/45decc6639b6/LICENSE)
The MIT License (MIT)
Copyright (c) 2015 Ulule
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/urfave/cli/v2
Version: v2.27.6
License: MIT (https://github.com/urfave/cli/blob/v2.27.6/LICENSE)
MIT License
Copyright (c) 2022 urfave/cli maintainers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/xrash/smetrics
Version: v0.0.0-20240521201337-686a1a2994c1
License: MIT (https://github.com/xrash/smetrics/blob/686a1a2994c1/LICENSE)
@@ -7588,8 +7586,8 @@ License: Apache-2.0 (https://github.com/go4org/go4/blob/214862532bf5/LICENSE)
--------------------------------------------------------------------------------
Package: golang.org/x/crypto
Version: v0.36.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.36.0:LICENSE)
Version: v0.37.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.37.0:LICENSE)
Copyright 2009 The Go Authors.
@@ -7622,8 +7620,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/image
Version: v0.25.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/image/+/v0.25.0:LICENSE)
Version: v0.26.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/image/+/v0.26.0:LICENSE)
Copyright 2009 The Go Authors.
@@ -7690,8 +7688,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/net
Version: v0.37.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/net/+/v0.37.0:LICENSE)
Version: v0.39.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/net/+/v0.39.0:LICENSE)
Copyright 2009 The Go Authors.
@@ -7758,8 +7756,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/sync/errgroup
Version: v0.12.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sync/+/v0.12.0:LICENSE)
Version: v0.13.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sync/+/v0.13.0:LICENSE)
Copyright 2009 The Go Authors.
@@ -7792,8 +7790,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/sys
Version: v0.31.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)
Version: v0.32.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sys/+/v0.32.0:LICENSE)
Copyright 2009 The Go Authors.
@@ -7826,8 +7824,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/text
Version: v0.23.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/text/+/v0.23.0:LICENSE)
Version: v0.24.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/text/+/v0.24.0:LICENSE)
Copyright 2009 The Go Authors.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
assets/examples/example.zip Normal file

Binary file not shown.

BIN
assets/examples/green.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -22,6 +22,8 @@ services:
links:
- "traefik:localssl.dev"
- "traefik:app.localssl.dev"
- "traefik:vision.localssl.dev"
- "traefik:qdrant.localssl.dev"
- "traefik:keycloak.localssl.dev"
- "traefik:dummy-oidc.localssl.dev"
- "traefik:dummy-webdav.localssl.dev"
@@ -112,7 +114,7 @@ services:
TF_CPP_MIN_LOG_LEVEL: 0 # show TensorFlow log messages for development
## Nvidia Video Transcoding (https://docs.photoprism.app/getting-started/advanced/transcoding/#nvidia-container-toolkit):
NVIDIA_VISIBLE_DEVICES: "all"
NVIDIA_DRIVER_CAPABILITIES: "compute,video,utility"
NVIDIA_DRIVER_CAPABILITIES: "all"
PHOTOPRISM_FFMPEG_ENCODER: "nvidia" # H.264/AVC encoder (software, intel, nvidia, apple, raspberry, or vaapi)
PHOTOPRISM_FFMPEG_SIZE: "1920" # video size limit in pixels (720-7680) (default: 3840)
PHOTOPRISM_FFMPEG_BITRATE: "50" # video bitrate limit in Mbit/s (default: 50)
@@ -148,6 +150,10 @@ services:
extends:
file: ./compose.yaml
service: qdrant
photoprism-vision:
extends:
file: ./compose.yaml
service: photoprism-vision
traefik:
extends:
file: ./compose.yaml
@@ -179,9 +185,3 @@ volumes:
driver: local
mariadb:
driver: local
## Create shared "photoprism-develop" network for connecting with services in other compose.yaml files
networks:
default:
name: photoprism
driver: bridge

View File

@@ -25,6 +25,8 @@ services:
links:
- "traefik:localssl.dev"
- "traefik:app.localssl.dev"
- "traefik:vision.localssl.dev"
- "traefik:qdrant.localssl.dev"
- "traefik:keycloak.localssl.dev"
- "traefik:dummy-oidc.localssl.dev"
- "traefik:dummy-webdav.localssl.dev"
@@ -170,6 +172,11 @@ services:
## Web UI: https://qdrant.localssl.dev/dashboard
qdrant:
image: qdrant/qdrant:latest
profiles: ["all", "qdrant"]
links:
- "traefik:localssl.dev"
- "traefik:app.localssl.dev"
- "traefik:vision.localssl.dev"
labels:
- "traefik.enable=true"
- "traefik.http.services.qdrant.loadbalancer.server.port=6333"
@@ -188,6 +195,34 @@ services:
- ./.qdrant.yaml:/qdrant/config/production.yaml
- ./storage/qdrant:/qdrant/storage
## PhotoPrism® Computer Vision API
## See: https://github.com/photoprism/photoprism-vision
photoprism-vision:
image: photoprism/vision:latest
entrypoint: [ "/app/venv/bin/flask" ]
command: [ "--app", "app", "run", "--debug", "--host", "0.0.0.0" ]
profiles: ["all", "vision"]
stop_grace_period: 5s
working_dir: "/app"
links:
- "traefik:localssl.dev"
- "traefik:app.localssl.dev"
- "traefik:qdrant.localssl.dev"
labels:
- "traefik.enable=true"
- "traefik.http.services.qdrant.loadbalancer.server.port=5000"
- "traefik.http.services.qdrant.loadbalancer.server.scheme=http"
- "traefik.http.routers.qdrant.entrypoints=websecure"
- "traefik.http.routers.qdrant.rule=Host(`vision.localssl.dev`)"
- "traefik.http.routers.qdrant.priority=3"
- "traefik.http.routers.qdrant.tls.domains[0].main=localssl.dev"
- "traefik.http.routers.qdrant.tls.domains[0].sans=*.localssl.dev"
- "traefik.http.routers.qdrant.tls=true"
expose:
- 5000
environment:
TF_CPP_MIN_LOG_LEVEL: 2
## Traefik v3 (Reverse Proxy)
## includes "*.localssl.dev" SSL certificate for test environments
## Docs: https://doc.traefik.io/traefik/

View File

@@ -39,6 +39,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism" \
S6_KEEP_ENV=1 \
S6_VERBOSITY=0 \
S6_LOGGING=0
# Copy scripts and package sources config.

View File

@@ -33,7 +33,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=4 \
PROG="photoprism" \
S6_KEEP_ENV=1 \
S6_KEEP_ENV=0 \
S6_VERBOSITY=0 \
S6_LOGGING=0
# Copy scripts and package sources config.
@@ -73,6 +74,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
/photoprism/storage/config \
/photoprism/storage/cache && \
/scripts/install-s6.sh && \
ln -sf /scripts/services/photoprism /etc/s6-overlay/s6-rc.d/photoprism && \
touch /etc/s6-overlay/s6-rc.d/user/contents.d/photoprism && \
/scripts/cleanup.sh
# Set default working directory.
@@ -83,4 +86,3 @@ EXPOSE 2342 2442 2443
# Set default entrypoint and command.
ENTRYPOINT ["/init"]
CMD ["/scripts/cmd.sh", "tail", "-f", "/dev/null"]

View File

@@ -39,6 +39,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism" \
S6_KEEP_ENV=1 \
S6_VERBOSITY=0 \
S6_LOGGING=0
# Copy scripts and package sources config.

View File

@@ -33,7 +33,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
TF_ENABLE_ONEDNN_OPTS=1 \
MALLOC_ARENA_MAX=4 \
PROG="photoprism" \
S6_KEEP_ENV=1 \
S6_KEEP_ENV=0 \
S6_VERBOSITY=0 \
S6_LOGGING=0
# Copy scripts and package sources config.
@@ -74,6 +75,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
/photoprism/storage/config \
/photoprism/storage/cache && \
/scripts/install-s6.sh && \
ln -sf /scripts/services/photoprism /etc/s6-overlay/s6-rc.d/photoprism && \
touch /etc/s6-overlay/s6-rc.d/user/contents.d/photoprism && \
/scripts/cleanup.sh
# Set default working directory.
@@ -84,4 +87,3 @@ EXPOSE 2342 2442 2443
# Set default entrypoint and command.
ENTRYPOINT ["/init"]
CMD ["/scripts/cmd.sh", "tail", "-f", "/dev/null"]

View File

@@ -39,6 +39,7 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
CGO_CFLAGS="-g -O2 -Wno-return-local-addr" \
PROG="photoprism" \
S6_KEEP_ENV=1 \
S6_VERBOSITY=0 \
S6_LOGGING=0
# Copy scripts and package sources config.

View File

@@ -92,7 +92,8 @@ ENV PHOTOPRISM_ARCH=$TARGETARCH \
PHOTOPRISM_AUTO_INDEX="300" \
PHOTOPRISM_AUTO_IMPORT="-1" \
PHOTOPRISM_INIT="https" \
S6_KEEP_ENV=1 \
S6_KEEP_ENV=0 \
S6_VERBOSITY=0 \
S6_LOGGING=0
# Copy dist files, scripts, and debian backports sources list.
@@ -132,6 +133,8 @@ RUN echo 'APT::Acquire::Retries "3";' > /etc/apt/apt.conf.d/80retries && \
/photoprism/storage/config \
/photoprism/storage/cache && \
/scripts/install-s6.sh && \
ln -sf /scripts/services/photoprism /etc/s6-overlay/s6-rc.d/photoprism && \
touch /etc/s6-overlay/s6-rc.d/user/contents.d/photoprism && \
/scripts/cleanup.sh
# Set default working directory.
@@ -142,4 +145,3 @@ EXPOSE 2342 2443
# Set default entrypoint and command.
ENTRYPOINT ["/init"]
CMD ["/scripts/cmd.sh", "/opt/photoprism/bin/photoprism", "start"]

View File

@@ -101,6 +101,3 @@ EXPOSE 2342 2443
# Copy app files.
COPY --from=build --chown=root:root --chmod=755 /opt/photoprism/ /opt/photoprism
# Start app.
CMD ["/scripts/cmd.sh", "/opt/photoprism/bin/photoprism", "start"]

View File

@@ -101,6 +101,3 @@ EXPOSE 2342 2443
# Copy app files.
COPY --from=build --chown=root:root --chmod=755 /opt/photoprism/ /opt/photoprism
# Start app.
CMD ["/scripts/cmd.sh", "/opt/photoprism/bin/photoprism", "start"]

View File

@@ -32,7 +32,7 @@ RUN apt-get update && apt-get upgrade && \
jq \
nano
# Install bazelisk
# Install bazelisk and llvm
RUN wget https://apt.llvm.org/llvm.sh && chmod u+x llvm.sh && \
./llvm.sh 17 && rm llvm.sh && \
ln -s /usr/bin/python3 /usr/bin/python && \

View File

@@ -1,4 +1,4 @@
FROM ubuntu:24.10
FROM ubuntu:22.04
LABEL maintainer="PhotoPrism UG <hello@photoprism.app>"
@@ -23,16 +23,19 @@ RUN apt-get update && apt-get upgrade && \
build-essential \
python3 \
ca-certificates \
llvm-17 \
clang-17 \
curl \
wget \
git \
lsb-release \
software-properties-common \
gnupg \
jq \
nano
# Install bazelisk
RUN ln -s /usr/bin/python3 /usr/bin/python && \
# Install bazelisk and llvm
RUN wget https://apt.llvm.org/llvm.sh && chmod u+x llvm.sh && \
./llvm.sh 17 && rm llvm.sh && \
ln -s /usr/bin/python3 /usr/bin/python && \
ln -s /usr/bin/clang-17 /usr/bin/clang && \
ln -s /usr/bin/clang++-17 /usr/bin/clang++ && \
ln -s /usr/bin/clang-cpp /usr/bin/clang-cpp && \

View File

@@ -16,7 +16,7 @@
"@babel/register": "^7.25.9",
"@babel/runtime": "^7.27.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.23.0",
"@eslint/js": "^9.24.0",
"@lcdp/offline-plugin": "^5.1.1",
"@mdi/font": "^7.4.47",
"@vue/compiler-sfc": "^3.5.13",
@@ -35,8 +35,8 @@
"css-loader": "^7.1.2",
"cssnano": "^7.0.6",
"easygettext": "^2.17.0",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.1",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-formatter-pretty": "^6.0.1",
"eslint-plugin-html": "^8.1.2",
"eslint-plugin-import": "^2.31.0",
@@ -51,7 +51,7 @@
"file-saver": "^2.0.5",
"floating-vue": "^5.2.2",
"globals": "^16.0.0",
"hls.js": "^1.6.0",
"hls.js": "^1.6.2",
"i": "^0.3.7",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
@@ -80,7 +80,7 @@
"regenerator-runtime": "^0.14.1",
"resolve-url-loader": "^5.0.0",
"sanitize-html": "^2.15.0",
"sass": "^1.86.1",
"sass": "^1.86.3",
"sass-loader": "^16.0.5",
"server": "^1.0.41",
"sockette": "^2.0.6",
@@ -98,15 +98,15 @@
"vue-sanitize-directive": "^0.2.1",
"vue-style-loader": "^4.1.3",
"vue3-gettext": "^2.4.0",
"vuetify": "^3.8.0",
"webpack": "^5.98.0",
"vuetify": "^3.8.1",
"webpack": "^5.99.5",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",
"webpack-hot-middleware": "^2.26.1",
"webpack-manifest-plugin": "^5.0.1",
"webpack-md5-hash": "^0.0.6",
"webpack-merge": "^6.0.1",
"webpack-plugin-vuetify": "^3.1.0"
"webpack-plugin-vuetify": "^3.1.1"
},
"devDependencies": {
"@vue/language-server": "^2.2.8"
@@ -2833,9 +2833,9 @@
"license": "MIT"
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz",
"integrity": "sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==",
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz",
"integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==",
"license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
@@ -2872,9 +2872,9 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz",
"integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==",
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
"integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^2.1.6",
@@ -2942,9 +2942,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.23.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz",
"integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==",
"version": "9.24.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz",
"integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==",
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3795,9 +3795,9 @@
}
},
"node_modules/@pkgr/core": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz",
"integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.2.tgz",
"integrity": "sha512-25L86MyPvnlQoX2MTIV2OiUcb6vJ6aRbFa9pbwByn95INKD5mFH2smgjDhq+fwJoqAgvgbdJLj6Tz7V9X5CFAQ==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
@@ -3807,9 +3807,9 @@
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.28",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==",
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"license": "MIT"
},
"node_modules/@rtsao/scc": {
@@ -3959,9 +3959,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -5499,9 +5499,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001710",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001710.tgz",
"integrity": "sha512-B5C0I0UmaGqHgo5FuqJ7hBd4L57A4dDD+Xi+XX1nXOoxGeDdY4Ko38qJYOyqznBVJEqON5p8P1x5zRR3+rsnxA==",
"version": "1.0.30001713",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
"integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
"funding": [
{
"type": "opencollective",
@@ -6901,9 +6901,9 @@
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -6995,9 +6995,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.131",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.131.tgz",
"integrity": "sha512-fJFRYXVEJgDCiqFOgRGJm8XR97hZ13tw7FXI9k2yC5hgY+nyzC2tMO8baq1cQR7Ur58iCkASx2zrkZPZUnfzPg==",
"version": "1.5.136",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.136.tgz",
"integrity": "sha512-kL4+wUTD7RSA5FHx5YwWtjDnEEkIIikFgWHR4P6fqjw1PPLlqYkxeOb++wAauAssat0YClCy8Y3C5SxgSkjibQ==",
"license": "ISC"
},
"node_modules/emmet": {
@@ -7330,18 +7330,18 @@
}
},
"node_modules/eslint": {
"version": "9.23.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz",
"integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==",
"version": "9.24.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.19.2",
"@eslint/config-array": "^0.20.0",
"@eslint/config-helpers": "^0.2.0",
"@eslint/core": "^0.12.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.23.0",
"@eslint/js": "9.24.0",
"@eslint/plugin-kit": "^0.2.7",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -7390,9 +7390,9 @@
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz",
"integrity": "sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw==",
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.2.tgz",
"integrity": "sha512-Epgp/EofAUeEpIdZkW60MHKvPyru1ruQJxPL+WIycnaPApuseK0Zpkrh/FwL9oIpQvIhJwV7ptOy0DWUjTlCiA==",
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
@@ -8384,9 +8384,9 @@
}
},
"node_modules/flow-remove-types": {
"version": "2.266.1",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.266.1.tgz",
"integrity": "sha512-dKaAhawO6bcBm5q/7FnX3gAAVZninLcDaCGIqbXIXfaLRTubnVzWgGDPXIPt72BH8uOwg6m3GifVPbn/W4+rcg==",
"version": "2.267.0",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.267.0.tgz",
"integrity": "sha512-NCXvOE6Z0N75pUkO5LnCgt+7kK78MRiB5d2xYHu87JYJ2Gl7Vz/JlwKTVwOclPUzDdTwznXY2sLpY+EMKzPqFg==",
"license": "MIT",
"dependencies": {
"hermes-parser": "0.25.1",
@@ -9110,9 +9110,9 @@
}
},
"node_modules/hls.js": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.0.tgz",
"integrity": "sha512-AlW8ymcDKZuKtzXCUmEy4nOcHRkebnShH6t6hC2+QJQP0WXlTUSSO9Kp22uSEYdCgpwkXEJsfOhqxrgO2tDctQ==",
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.2.tgz",
"integrity": "sha512-rx+pETSCJEDThm/JCm8CuadcAC410cVjb1XVXFNDKFuylaayHk1+tFxhkjvnMDAfqsJHxZXDAJ3Uc2d5xQyWlQ==",
"license": "Apache-2.0"
},
"node_modules/html-entities": {
@@ -9429,9 +9429,9 @@
}
},
"node_modules/ioredis": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.0.tgz",
"integrity": "sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==",
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
"integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "^1.1.1",
@@ -15270,12 +15270,12 @@
}
},
"node_modules/synckit": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.2.tgz",
"integrity": "sha512-1IUffI8zZ8qUMB3NUJIjk0RpLroG/8NkQDAWH1NbB2iJ0/5pn3M8rxfNzMz4GH9OnYaGYn31LEDSXJp/qIlxgA==",
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.3.tgz",
"integrity": "sha512-szhWDqNNI9etJUvbZ1/cx1StnZx8yMmFxme48SwR4dty4ioSY50KEZlpv0qAfgc1fpRzuh9hBXEzoCpJ779dLg==",
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.0",
"@pkgr/core": "^0.2.1",
"tslib": "^2.8.1"
},
"engines": {
@@ -15665,9 +15665,9 @@
}
},
"node_modules/typescript": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -16360,9 +16360,9 @@
}
},
"node_modules/vue-eslint-parser": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.1.2.tgz",
"integrity": "sha512-1guOfYgNlD7JH2popr/bt5vc7Mzt6quRCnEbqLgpMHvoHEGV1oImzdqrLd+oMD76cHt8ilBP4cda9WA72TLFDQ==",
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.1.3.tgz",
"integrity": "sha512-dbCBnd2e02dYWsXoqX5yKUZlOt+ExIpq7hmHKPb5ZqKcjf++Eo0hMseFTZMLKThrUk61m+Uv6A2YSBve6ZvuDQ==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -16596,9 +16596,9 @@
}
},
"node_modules/vuetify": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.8.0.tgz",
"integrity": "sha512-ROC0Xq2G/25ZyUpQMhaynMyXZBJY1WbOGlqOB810yubp8hfY8RlrOw+mzXJonOq6jylCY32muQ9xiJF1JPTLVA==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.8.1.tgz",
"integrity": "sha512-3qReKBBWIIdJJmwnFU1blVIKHDtnLfIP7kk0MwUrrfjYkWmsDpsymtDnsukkTCnlJ1WvhLr64eQFosr0RVbj9w==",
"license": "MIT",
"engines": {
"node": "^12.20 || >=14.13"
@@ -16648,9 +16648,9 @@
}
},
"node_modules/webpack": {
"version": "5.98.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
"version": "5.99.5",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.5.tgz",
"integrity": "sha512-q+vHBa6H9qwBLUlHL4Y7L0L1/LlyBKZtS9FHNCQmtayxjI5RKC9yD8gpvLeqGv5lCQp1Re04yi0MF40pf30Pvg==",
"license": "MIT",
"dependencies": {
"@types/eslint-scope": "^3.7.7",

View File

@@ -34,7 +34,7 @@
"@babel/register": "^7.25.9",
"@babel/runtime": "^7.27.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.23.0",
"@eslint/js": "^9.24.0",
"@lcdp/offline-plugin": "^5.1.1",
"@mdi/font": "^7.4.47",
"@vue/compiler-sfc": "^3.5.13",
@@ -53,8 +53,8 @@
"css-loader": "^7.1.2",
"cssnano": "^7.0.6",
"easygettext": "^2.17.0",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.1",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-formatter-pretty": "^6.0.1",
"eslint-plugin-html": "^8.1.2",
"eslint-plugin-import": "^2.31.0",
@@ -69,7 +69,7 @@
"file-saver": "^2.0.5",
"floating-vue": "^5.2.2",
"globals": "^16.0.0",
"hls.js": "^1.6.0",
"hls.js": "^1.6.2",
"i": "^0.3.7",
"karma": "^6.4.4",
"karma-chrome-launcher": "^3.2.0",
@@ -98,7 +98,7 @@
"regenerator-runtime": "^0.14.1",
"resolve-url-loader": "^5.0.0",
"sanitize-html": "^2.15.0",
"sass": "^1.86.1",
"sass": "^1.86.3",
"sass-loader": "^16.0.5",
"server": "^1.0.41",
"sockette": "^2.0.6",
@@ -116,15 +116,15 @@
"vue-sanitize-directive": "^0.2.1",
"vue-style-loader": "^4.1.3",
"vue3-gettext": "^2.4.0",
"vuetify": "^3.8.0",
"webpack": "^5.98.0",
"vuetify": "^3.8.1",
"webpack": "^5.99.5",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1",
"webpack-hot-middleware": "^2.26.1",
"webpack-manifest-plugin": "^5.0.1",
"webpack-md5-hash": "^0.0.6",
"webpack-merge": "^6.0.1",
"webpack-plugin-vuetify": "^3.1.0"
"webpack-plugin-vuetify": "^3.1.1"
},
"devDependencies": {
"@vue/language-server": "^2.2.8"

View File

@@ -2,8 +2,6 @@
<div id="photoprism" :class="['theme-' + themeName]">
<p-loading-bar height="4"></p-loading-bar>
<p-notify></p-notify>
<v-app :class="appClass">
<p-navigation></p-navigation>
@@ -13,6 +11,7 @@
</v-app>
<p-dialogs></p-dialogs>
<p-notify></p-notify>
</div>
</template>

View File

@@ -742,33 +742,24 @@ export default class Config {
return;
}
if (tokens.previewToken) {
if (this.previewToken !== tokens.previewToken) {
this.previewToken = tokens.previewToken;
}
if (this.values.previewToken !== tokens.previewToken) {
this.values.previewToken = tokens.previewToken;
}
if (tokens.previewToken && this.values?.previewToken !== tokens.previewToken) {
this.values.previewToken = tokens.previewToken;
}
if (tokens.downloadToken) {
if (this.downloadToken !== tokens.downloadToken) {
this.downloadToken = tokens.downloadToken;
}
if ((this.values.downloadToken = tokens.downloadToken)) {
this.values.downloadToken = tokens.downloadToken;
}
if (tokens.downloadToken && this.values?.downloadToken !== tokens.downloadToken) {
this.values.downloadToken = tokens.downloadToken;
}
this.updateTokens();
}
// updateTokens updates the security tokens required to load thumbnails and download files from the server.
updateTokens() {
if (this.values["previewToken"]) {
if (this.values?.previewToken && this.previewToken !== this.values.previewToken) {
this.previewToken = this.values.previewToken;
}
if (this.values["downloadToken"]) {
if (this.values?.downloadToken && this.downloadToken !== this.values.downloadToken) {
this.downloadToken = this.values.downloadToken;
}
}

View File

@@ -243,6 +243,9 @@ export class View {
document.addEventListener("focusin", (ev) => {
console.log("%cdocument.focusin", "color: #B2EBF2;", ev.target);
});
document.addEventListener("focusout", (ev) => {
console.log("%cdocument.focusout", "color: #B2EBF2;", ev.target);
});
}
}

View File

@@ -11,6 +11,7 @@
v-bind="props"
density="comfortable"
:icon="buttonIcon"
:tabindex="tabindex"
class="action-menu__btn"
:class="buttonClass"
></v-btn>
@@ -49,6 +50,10 @@ export default {
type: Function,
default: () => [],
},
tabindex: {
type: Number,
default: 3,
},
buttonClass: {
type: String,
default: "",

View File

@@ -194,11 +194,9 @@ export default {
attach: document.body,
},
VOverlay: {
scrim: true,
transition: false,
openDelay: 0,
closeDelay: 0,
attach: document.body,
},
VExpansionPanel: {
tile: true,

View File

@@ -319,7 +319,11 @@ export default {
}
},
focusContent(ev) {
if (this.$refs.content && this.$refs.content instanceof HTMLElement) {
if (
this.$refs.content &&
this.$refs.content instanceof HTMLElement &&
document.activeElement !== this.$refs.content
) {
this.$refs.content.focus();
if (this.debug && ev) {
@@ -940,7 +944,7 @@ export default {
}
// Focus content element.
this.$refs.content.focus();
this.focusContent();
// Create PhotoSwipe instance.
let lightbox = new Lightbox(options);
@@ -1501,7 +1505,7 @@ export default {
}
// Ensure that content is focused.
this.$refs.content.focus();
this.focusContent();
},
// Called when the user clicks on the PhotoSwipe lightbox background,
// see https://photoswipe.com/click-and-tap-actions.
@@ -2190,7 +2194,7 @@ export default {
// Resize and focus content element.
this.$nextTick(() => {
this.resize(true);
this.$refs.content.focus();
this.focusContent();
});
},
// Hides the lightbox sidebar, if visible.
@@ -2206,7 +2210,7 @@ export default {
// Resize and focus content element.
this.$nextTick(() => {
this.resize(true);
this.$refs.content.focus();
this.focusContent();
});
},
toggleControls() {

View File

@@ -67,7 +67,7 @@
<v-list-item class="px-3" :elevation="0" :ripple="false" @click.stop.prevent="goHome">
<template #prepend>
<div class="v-avatar bg-transparent nav-logo">
<a :href="siteUrl" @click.stop.prevent="goHome">
<a :href="siteUrl" tabindex="-1" @click.stop.prevent="goHome">
<img :src="appIcon" :alt="appName" :class="{ 'animate-hue': indexing }" />
</a>
</div>
@@ -77,6 +77,7 @@
icon
variant="text"
:elevation="0"
tabindex="-1"
class="nav-minimize hidden-sm-and-down"
:ripple="false"
:title="$gettext('Minimize')"
@@ -99,7 +100,7 @@
color="primary"
open-strategy="single"
:density="$vuetify.display.smAndDown ? 'compact' : 'default'"
tabindex="0"
tabindex="-1"
>
<v-list-item v-if="isMini && !isRestricted" class="nav-expand" @click.stop="toggleIsMini()">
<v-icon :icon="rtl ? 'mdi-chevron-left' : 'mdi-chevron-right'" class="ma-auto"></v-icon>

View File

@@ -1,35 +1,39 @@
<template>
<div v-if="visible" id="p-notify" tabindex="-1">
<v-snackbar
:model-value="snackbar"
:class="'p-notify--' + message.color"
class="p-notify clickable"
@click.stop.prevent="showNext"
@update:model-value="onSnackbar"
>
<v-icon
v-if="message.icon"
:icon="'mdi-' + message.icon"
:color="message.color"
class="p-notify_icon"
start
></v-icon>
{{ message.text }}
<template #actions>
<v-btn
icon="mdi-close"
:color="'on-' + message.color"
variant="text"
class="p-notify__close"
<teleport to="body">
<transition name="fade-transition">
<div v-if="visible" id="p-notify" tabindex="-1">
<div
:class="'p-notify--' + message.color"
class="v-snackbar v-snackbar--bottom v-snackbar--center p-notify"
role="alert"
tabindex="-1"
@click.stop.prevent="showNext"
></v-btn>
</template>
</v-snackbar>
</div>
>
<div class="v-snackbar__wrapper v-snackbar--variant-flat">
<span class="v-snackbar__underlay"></span>
<div tabindex="-1" class="v-snackbar__content">
<i
v-if="message.icon"
:class="['text-' + message.color, 'mdi-' + message.icon]"
class="mdi v-icon notranslate p-notify__icon"
aria-hidden="true"
></i>
<div class="p-notify__text">
{{ message.text }}
</div>
<i
:class="'text-on-' + message.color"
class="mdi-close mdi v-icon notranslate p-notify__close"
aria-hidden="true"
></i>
</div>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script>
let focusElement = null;
export default {
name: "PNotify",
data() {
@@ -164,22 +168,12 @@ export default {
this.message.delay = this.defaultDelay;
}
if (!focusElement) {
focusElement = document.activeElement;
}
if (!this.snackbar) {
this.snackbar = true;
}
this.visible = true;
this.$nextTick(() => {
if (focusElement && typeof focusElement.focus === "function" && document.activeElement !== focusElement) {
focusElement.focus();
}
});
setTimeout(() => {
this.lastText = "";
this.showNext();
@@ -188,15 +182,6 @@ export default {
this.lastText = "";
this.visible = false;
this.message.text = "";
// Return focus to the previously active element, if any.
if (focusElement) {
if (typeof focusElement.focus === "function" && document.activeElement !== focusElement) {
focusElement.focus();
}
focusElement = null;
}
}
},
},

View File

@@ -122,13 +122,17 @@ export default {
onLoad() {
this.loading = true;
this.$nextTick(() => {
this.$refs.input.focus();
if (document.activeElement !== this.$refs.input) {
this.$refs.input.focus();
}
});
},
onLoaded() {
this.loading = false;
this.$nextTick(() => {
this.$refs.input.focus();
if (document.activeElement !== this.$refs.input) {
this.$refs.input.focus();
}
});
},
reset() {

View File

@@ -209,7 +209,10 @@ export default {
}
if (ev.target && ev.target instanceof HTMLElement && this.$refs.content?.$el instanceof HTMLElement) {
if (!ev.target.closest(".p-photo-edit-dialog") || ev.target?.disabled) {
if (
document.activeElement !== this.$refs.content.$el &&
(!ev.target.closest(".p-photo-edit-dialog") || ev.target?.disabled)
) {
this.$refs.content?.$el.focus();
}
}

View File

@@ -18,6 +18,7 @@
<v-text-field
:model-value="filter.q"
:density="density"
tabindex="1"
hide-details
clearable
single-line
@@ -62,16 +63,24 @@
group
class="ms-1"
>
<v-btn value="cards" icon="mdi-view-column" class="ps-1 action-view-cards" @click="setView('cards')"></v-btn>
<v-btn
value="cards"
tabindex="2"
icon="mdi-view-column"
class="ps-1 action-view-cards"
@click="setView('cards')"
></v-btn>
<v-btn
v-if="listView"
value="list"
tabindex="2"
icon="mdi-view-list"
class="action-view-list"
@click="setView('list')"
></v-btn>
<v-btn
value="mosaic"
tabindex="2"
icon="mdi-view-comfy"
class="pe-1 action-view-mosaic"
@click="setView('mosaic')"
@@ -82,12 +91,18 @@
v-if="canDelete && context === 'archive' && config.count.archived > 0"
:title="$gettext('Delete All')"
icon="mdi-delete-sweep"
tabindex="3"
class="action-delete-all ms-1"
@click.stop="deleteAll"
>
</v-btn>
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" button-class="ms-1"></p-action-menu>
<p-action-menu
v-if="$vuetify.display.mdAndUp"
:items="menuActions"
:tabindex="3"
button-class="ms-1"
></p-action-menu>
</template>
<template v-else>
<v-spacer></v-spacer>
@@ -110,6 +125,7 @@
:model-value="filter.country"
:label="$gettext('Country')"
:menu-props="{ maxHeight: 346 }"
tabindex="4"
single-line
hide-details
variant="solo-filled"
@@ -131,6 +147,7 @@
:model-value="filter.camera"
:label="$gettext('Camera')"
:menu-props="{ maxHeight: 346 }"
tabindex="5"
single-line
hide-details
variant="solo-filled"
@@ -151,6 +168,7 @@
id="viewSelect"
:model-value="settings.view"
:label="$gettext('View')"
tabindex="6"
single-line
hide-details
variant="solo-filled"
@@ -171,6 +189,7 @@
:model-value="filter.order"
:label="$gettext('Sort Order')"
:menu-props="{ maxHeight: 400 }"
tabindex="7"
single-line
variant="solo-filled"
:density="density"
@@ -190,6 +209,7 @@
:model-value="filter.year"
:label="$gettext('Year')"
:menu-props="{ maxHeight: 346 }"
tabindex="8"
single-line
variant="solo-filled"
:density="density"
@@ -209,6 +229,7 @@
:model-value="filter.month"
:label="$gettext('Month')"
:menu-props="{ maxHeight: 346 }"
tabindex="9"
single-line
variant="solo-filled"
:density="density"
@@ -242,6 +263,7 @@
:model-value="filter.color"
:label="$gettext('Color')"
:menu-props="{ maxHeight: 346 }"
tabindex="10"
single-line
hide-details
variant="solo-filled"
@@ -262,6 +284,7 @@
:model-value="filter.label"
:label="$gettext('Category')"
:menu-props="{ maxHeight: 346 }"
tabindex="11"
single-line
hide-details
variant="solo-filled"

View File

@@ -220,7 +220,10 @@ export default {
}
if (ev.target && ev.target instanceof HTMLElement && this.$refs.form?.$el instanceof HTMLElement) {
if (!ev.target.closest(".p-upload-dialog") || ev.target?.disabled) {
if (
document.activeElement !== this.$refs.form.$el &&
(!ev.target.closest(".p-upload-dialog") || ev.target?.disabled)
) {
this.$refs.form?.$el.focus();
}
}

View File

@@ -233,6 +233,7 @@ table td > .action-buttons {
.v-menu>.v-overlay__content>.v-list.action-menu__list>.v-list-item.v-list-item--density-compact.action-menu__item {
padding: 8px 10px;
margin: 2px 0;
user-select: none;
}
.v-menu.action-menu.action-menu--lightbox .action-menu__list {

View File

@@ -396,6 +396,7 @@
height: 48px;
width: 44px;
display: flex;
user-select: none;
justify-content: center !important;
align-items: center !important;
}

View File

@@ -1,23 +1,85 @@
/* Notifications */
.v-snackbar .p-notify {
#p-notify {
position: fixed;
z-index: 2500;
top: auto;
left: 0;
right: 0;
bottom: 0;
margin-top: auto;
margin-left: auto;
margin-right: auto;
display: flex;
gap: 4px;
align-items: center;
flex-direction: column;
justify-items: center;
font-size: 0.875rem;
font-weight: 400;
user-select: none;
width: auto;
height: auto;
max-width: 440px;
pointer-events: none;
}
.v-snackbar {
margin: 16px;
#p-notify .v-snackbar {
margin-left: auto;
margin-right: auto;
width: auto;
min-width: 200px;
max-width: 440px;
margin-bottom: 24px;
}
.v-snackbar__wrapper {
position: relative;
min-width: 320px;
#p-notify .v-snackbar .v-snackbar__wrapper {
cursor: pointer;
color: rgba(var(--v-theme-on-surface), 1);
background-color: rgba(var(--v-theme-surface), 0.32);
backdrop-filter: blur(6px);
box-shadow: 0 2px 1px -1px var(--v-shadow-key-umbra-opacity, #0003), 0 1px 1px 0 var(--v-shadow-key-penumbra-opacity, #00000024), 0 1px 3px 0 var(--v-shadow-key-ambient-opacity, #0000001f) !important;
padding: 10px 16px;
pointer-events: auto;
border-radius: 9999px;
}
.v-snackbar__wrapper .p-notify__close {
opacity: 0.8;
#p-notify .v-snackbar__wrapper .v-snackbar__content {
z-index: 2;
display: flex;
gap: 4px;
align-items: center;
align-self: center;
flex-wrap: nowrap;
flex-direction: row;
justify-content: space-between;
justify-items: center;
padding: 0;
margin: 0;
}
#p-notify .v-snackbar__wrapper .v-snackbar__content .p-notify__icon {
margin: 0;
align-self: center;
font-size: 22px;
height: 22px;
width: 22px;
}
#p-notify .v-snackbar__wrapper .v-snackbar__content .p-notify__text {
flex-grow: 1;
margin: 2px 8px;
padding: 0;
text-align: start;
align-self: center;
}
#p-notify .v-snackbar__wrapper .v-snackbar__content .p-notify__close {
opacity: 0.78;
font-size: 24px;
height: 24px;
width: 24px;
cursor: pointer;
}
.v-snackbar__wrapper .v-snackbar__underlay {
@@ -32,17 +94,6 @@
border-radius: inherit;
}
.v-snackbar__wrapper .v-snackbar__content {
z-index: 2;
display: flex;
gap: 4px;
align-items: center;
flex-direction: row;
justify-content: stretch;
justify-items: center;
padding: 8px 14px;
}
.v-snackbar.p-notify--success .v-snackbar__wrapper {
background-color: rgba(var(--v-theme-success), 0.36) !important;
color: rgba(var(--v-theme-on-success), 1) !important;
@@ -62,3 +113,10 @@
background-color: rgba(var(--v-theme-error), 0.42) !important;
color: rgba(var(--v-theme-on-error), 1) !important;
}
@media only screen and (min-width: 600px) {
#p-notify .v-snackbar {
min-width: 280px;
margin-bottom: 16px;
}
}

View File

@@ -504,6 +504,10 @@ export default {
this.loadMore(true);
},
reset() {
this.results = [];
this.lightbox.results = [];
},
search() {
/**
* search is called on mount or route change. If the route changed to an
@@ -567,6 +571,9 @@ export default {
});
}
})
.catch(() => {
this.reset();
})
.finally(() => {
this.dirty = false;
this.loading = false;

View File

@@ -15,6 +15,7 @@
<v-text-field
:model-value="filter.q"
:density="density"
tabindex="1"
hide-details
clearable
overflow
@@ -48,12 +49,13 @@
<v-btn
v-if="canManage && staticFilter.type === 'album'"
:title="$gettext('Add Album')"
tabindex="2"
icon="mdi-plus"
class="action-add ms-1"
@click.prevent="create()"
></v-btn>
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" button-class="ms-1"></p-action-menu>
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" :tabindex="3" button-class="ms-1"></p-action-menu>
</v-toolbar>
<div class="toolbar-expansion-panel">
@@ -67,6 +69,7 @@
:label="$gettext('Year')"
:disabled="context === 'state'"
:menu-props="{ maxHeight: 346 }"
tabindex="4"
single-line
hide-details
variant="solo-filled"
@@ -87,6 +90,7 @@
:model-value="filter.category"
:label="$gettext('Category')"
:menu-props="{ maxHeight: 346 }"
tabindex="5"
single-line
hide-details
variant="solo-filled"
@@ -107,6 +111,7 @@
:model-value="filter.order"
:label="$gettext('Sort Order')"
:menu-props="{ maxHeight: 400 }"
tabindex="6"
single-line
hide-details
variant="solo-filled"
@@ -912,6 +917,9 @@ export default {
return params;
},
reset() {
this.results = [];
},
search() {
/**
* re-creating the last scroll-position should only ever happen when using
@@ -970,6 +978,9 @@ export default {
});
}
})
.catch(() => {
this.reset();
})
.finally(() => {
this.dirty = false;
this.loading = false;

View File

@@ -19,6 +19,7 @@
overflow
single-line
rounded
tabindex="1"
variant="solo-filled"
:density="density"
validate-on="invalid-input"
@@ -45,6 +46,7 @@
<v-btn
v-if="!filter.all"
icon="mdi-eye"
tabindex="2"
:title="$gettext('Show more')"
class="action-show-all ms-1"
@click.stop="showAll"
@@ -53,12 +55,18 @@
<v-btn
v-else
icon="mdi-eye-off"
tabindex="2"
:title="$gettext('Show less')"
class="action-show-important ms-1"
@click.stop="showImportant"
>
</v-btn>
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" button-class="ms-1"></p-action-menu>
<p-action-menu
v-if="$vuetify.display.mdAndUp"
:items="menuActions"
:tabindex="3"
button-class="ms-1"
></p-action-menu>
</v-toolbar>
</v-form>
@@ -654,6 +662,9 @@ export default {
this.loadMore();
},
reset() {
this.results = [];
},
search() {
/**
* re-creating the last scroll-position should only ever happen when using
@@ -707,6 +718,9 @@ export default {
});
}
})
.catch(() => {
this.reset();
})
.finally(() => {
this.dirty = false;
this.loading = false;

View File

@@ -18,7 +18,7 @@
</router-link>
</v-toolbar-title>
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" class="action-reload" @click.stop="refresh"> </v-btn>
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" tabindex="1" class="action-reload" @click.stop="refresh"> </v-btn>
</v-toolbar>
</v-form>
@@ -437,6 +437,9 @@ export default {
return "";
},
reset() {
this.results = [];
},
search() {
// Don't query the same data more than once
if (!this.dirty && JSON.stringify(this.lastFilter) === JSON.stringify(this.filter)) {
@@ -479,6 +482,9 @@ export default {
);
}
})
.catch(() => {
this.reset();
})
.finally(() => {
this.dirty = false;
this.loading = false;

View File

@@ -19,6 +19,7 @@
overflow
single-line
rounded
tabindex="1"
variant="solo-filled"
:density="density"
validate-on="invalid-input"
@@ -45,12 +46,13 @@
<v-btn
v-if="!isPublic"
:title="$gettext('Delete All')"
tabindex="2"
icon="mdi-delete-sweep"
class="action-delete action-delete-all ms-1"
@click.stop="onDelete"
>
</v-btn>
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" button-class="ms-1"></p-action-menu>
<p-action-menu v-if="$vuetify.display.mdAndUp" :items="menuActions" :tabindex="3" button-class="ms-1"></p-action-menu>
</v-toolbar>
</v-form>
<div v-if="loading" class="p-page__loading">

View File

@@ -4,11 +4,12 @@
<v-toolbar density="compact" class="page-toolbar" color="secondary-light">
<v-spacer></v-spacer>
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" class="action-reload" @click.stop="refresh"> </v-btn>
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" tabindex="2" class="action-reload" @click.stop="refresh"> </v-btn>
<v-btn
v-if="!filter.hidden"
:title="$gettext('Show hidden')"
tabindex="3"
icon="mdi-eye"
class="action-show-hidden"
@click.stop="onShowHidden"
@@ -16,6 +17,7 @@
</v-btn>
<v-btn
v-else
tabindex="3"
:title="$gettext('Exclude hidden')"
icon="mdi-eye-off"
class="action-exclude-hidden"
@@ -544,6 +546,9 @@ export default {
this.loadMore();
},
reset() {
this.results = [];
},
search() {
this.scrollDisabled = true;
@@ -577,6 +582,9 @@ export default {
this.$notify.info(this.$gettextInterpolate(this.$gettext("%{n} people found"), { n: this.results.length }));
}
})
.catch(() => {
this.reset();
})
.finally(() => {
this.dirty = false;
this.loading = false;

View File

@@ -5,6 +5,7 @@
<v-text-field
v-if="canSearch"
:model-value="filter.q"
tabindex="1"
hide-details
clearable
single-line
@@ -32,12 +33,13 @@
"
></v-text-field>
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" class="action-reload" @click.stop="refresh"></v-btn>
<v-btn :title="$gettext('Refresh')" icon="mdi-refresh" tabindex="2" class="action-reload" @click.stop="refresh"></v-btn>
<template v-if="canManage">
<v-btn
v-if="!filter.hidden"
:title="$gettext('Show hidden')"
tabindex="3"
icon="mdi-eye"
class="action-show-hidden"
@click.stop="onShowHidden"
@@ -46,6 +48,7 @@
<v-btn
v-else
:title="$gettext('Exclude hidden')"
tabindex="3"
icon="mdi-eye-off"
class="action-exclude-hidden"
@click.stop="onExcludeHidden()"
@@ -685,6 +688,9 @@ export default {
this.loadMore();
},
reset() {
this.results = [];
},
search() {
/**
* re-creating the last scroll-position should only ever happen when using
@@ -738,6 +744,9 @@ export default {
});
}
})
.catch(() => {
this.reset();
})
.finally(() => {
this.dirty = false;
this.loading = false;

View File

@@ -637,6 +637,10 @@ export default {
this.loadMore(true);
},
reset() {
this.results = [];
this.lightbox.results = [];
},
search() {
/**
* search is called on mount or route change. If the route changed to an
@@ -706,6 +710,9 @@ export default {
});
}
})
.catch(() => {
this.reset();
})
.finally(() => {
this.dirty = false;
this.loading = false;

View File

@@ -31,6 +31,7 @@
<v-text-field
v-model.lazy.trim="filter.q"
:placeholder="$gettext('Search')"
tabindex="1"
density="compact"
flat
single-line
@@ -750,6 +751,13 @@ export default {
this.search(true);
},
reset() {
Object.assign(this.result, { features: [] });
if (this.map) {
this.map.getSource("photos").setData(this.result);
this.updateMarkers();
}
},
search(force) {
if (this.loading) {
return;
@@ -778,6 +786,7 @@ export default {
.get("geo", options)
.then((response) => {
if (!response.data.features || response.data.features.length === 0) {
this.reset();
this.initialized = true;
this.loading = false;
@@ -806,6 +815,7 @@ export default {
this.updateMarkers();
})
.catch(() => {
this.reset();
this.initialized = true;
this.loading = false;
});

View File

@@ -19,6 +19,7 @@
v-model="settings.ui.theme"
:disabled="busy"
:items="themes"
tabindex="2"
item-title="text"
item-value="value"
:label="$gettext('Theme')"
@@ -33,6 +34,7 @@
v-model="settings.ui.language"
:disabled="busy"
:items="languages"
tabindex="2"
item-title="text"
item-value="value"
:label="$gettext('Language')"
@@ -47,6 +49,7 @@
<v-select
v-model="settings.ui.timeZone"
:disabled="busy"
tabindex="2"
item-value="ID"
item-title="Name"
:items="options.TimeZones($gettext('Default'))"
@@ -62,6 +65,7 @@
v-model="settings.ui.startPage"
:disabled="busy"
:items="options.StartPages(settings.features)"
tabindex="2"
item-title="text"
item-value="value"
:label="$gettext('Start Page')"
@@ -82,6 +86,7 @@
<v-checkbox
v-model="settings.features.people"
:disabled="busy"
tabindex="2"
class="ma-0 pa-0 input-people"
density="compact"
:label="$gettext('People')"
@@ -97,6 +102,7 @@
<v-checkbox
v-model="settings.features.calendar"
:disabled="busy"
tabindex="2"
class="ma-0 pa-0 input-calendar"
density="compact"
:label="$gettext('Calendar')"
@@ -112,6 +118,7 @@
<v-checkbox
v-model="settings.features.moments"
:disabled="busy"
tabindex="2"
class="ma-0 pa-0 input-moments"
density="compact"
:label="$gettext('Moments')"
@@ -127,6 +134,7 @@
<v-checkbox
v-model="settings.features.labels"
:disabled="busy"
tabindex="2"
class="ma-0 pa-0 input-labels"
density="compact"
:label="$gettext('Labels')"
@@ -141,6 +149,7 @@
<v-checkbox
v-model="settings.features.private"
:disabled="busy"
tabindex="2"
class="ma-0 pa-0 input-private"
density="compact"
:label="$gettext('Private')"
@@ -160,6 +169,7 @@
:disabled="busy || config.readonly || isDemo"
class="ma-0 pa-0 input-upload"
density="compact"
tabindex="2"
:label="$gettext('Upload')"
:hint="$gettext('Add files to your library via Web Upload.')"
prepend-icon="mdi-cloud-upload"
@@ -175,6 +185,7 @@
:disabled="busy || isDemo"
class="ma-0 pa-0 input-download"
density="compact"
tabindex="2"
:label="$gettext('Download')"
:hint="$gettext('Download single files and zip archives.')"
prepend-icon="mdi-download"
@@ -190,6 +201,7 @@
:disabled="busy || config.readonly || isDemo"
class="ma-0 pa-0 input-import"
density="compact"
tabindex="2"
:label="$gettext('Import')"
:hint="$gettext('Imported files will be sorted by date and given a unique name.')"
prepend-icon="mdi-folder-plus"
@@ -205,6 +217,7 @@
:disabled="busy"
class="ma-0 pa-0 input-share"
density="compact"
tabindex="2"
:label="$gettext('Share')"
:hint="$gettext('Upload to WebDAV and share links with friends.')"
prepend-icon="mdi-share-variant"
@@ -220,6 +233,7 @@
:disabled="busy || isDemo"
class="ma-0 pa-0 input-edit"
density="compact"
tabindex="2"
:label="$gettext('Edit')"
:hint="$gettext('Change photo titles, locations, and other metadata.')"
prepend-icon="mdi-pencil"
@@ -235,6 +249,7 @@
:disabled="busy || isDemo"
class="ma-0 pa-0 input-archive"
density="compact"
tabindex="2"
:label="$gettext('Archive')"
:hint="$gettext('Hide photos that have been moved to archive.')"
prepend-icon="mdi-package-down"
@@ -250,6 +265,7 @@
:disabled="busy"
class="ma-0 pa-0 input-delete"
density="compact"
tabindex="2"
:label="$gettext('Delete')"
:hint="$gettext('Permanently remove files to free up storage.')"
prepend-icon="mdi-delete"
@@ -264,6 +280,7 @@
:disabled="busy"
class="ma-0 pa-0 input-services"
density="compact"
tabindex="2"
:label="$gettext('Services')"
:hint="$gettext('Share your pictures with other apps and services.')"
prepend-icon="mdi-sync"
@@ -279,6 +296,7 @@
:disabled="busy || isDemo"
class="ma-0 pa-0 input-library"
density="compact"
tabindex="2"
:label="$gettext('Library')"
:hint="$gettext('Index and import files through the user interface.')"
prepend-icon="mdi-film"
@@ -294,6 +312,7 @@
:disabled="busy"
class="ma-0 pa-0 input-files"
density="compact"
tabindex="2"
:label="$gettext('Originals')"
:hint="$gettext('Browse indexed files and folders in Library.')"
prepend-icon="mdi-file-tree"
@@ -309,6 +328,7 @@
:disabled="busy"
class="ma-0 pa-0 input-logs"
density="compact"
tabindex="2"
:label="$gettext('Logs')"
:hint="$gettext('Show server logs in Library.')"
prepend-icon="mdi-playlist-check"
@@ -324,6 +344,7 @@
:disabled="busy || isDemo"
class="ma-0 pa-0 input-account"
density="compact"
tabindex="2"
:label="$gettext('Account')"
:hint="$gettext('Change personal profile and security settings.')"
prepend-icon="mdi-shield-account-variant"
@@ -339,6 +360,7 @@
:disabled="busy || isDemo"
class="ma-0 pa-0 input-places"
density="compact"
tabindex="2"
:label="$gettext('Places')"
:hint="$gettext('Search and display photos on a map.')"
prepend-icon="mdi-map-marker"

View File

@@ -98,7 +98,7 @@ test.meta("testID", "settings-general-002").meta({ type: "short", mode: "auth" }
test.meta("testID", "settings-general-003").meta({ type: "short", mode: "auth" })(
"Common: Disable pages: import, originals, logs, moments, places, library, calendar, services, account",
async (t) => {
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await toolbar.search("Tübingen");
await t.expect(page.cardLocation.exists).ok();
@@ -173,7 +173,7 @@ test.meta("testID", "settings-general-003").meta({ type: "short", mode: "auth" }
}
await menu.openPage("browse");
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await t.expect(page.cardLocation.exists).notOk();
@@ -269,7 +269,7 @@ test.meta("testID", "settings-general-003").meta({ type: "short", mode: "auth" }
test.meta("testID", "settings-general-004").meta({ type: "short", mode: "auth" })(
"Common: Disable people and labels",
async (t) => {
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await t.click(page.cardTitle.nth(0));
await t.click(photoedit.labelsTab);
@@ -286,7 +286,7 @@ test.meta("testID", "settings-general-004").meta({ type: "short", mode: "auth" }
await t.click(settings.peopleCheckbox).click(settings.labelsCheckbox);
await t.eval(() => location.reload());
await menu.openPage("browse");
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await t.click(page.cardTitle.nth(0));
await t.click(photoedit.labelsTab);
@@ -524,7 +524,7 @@ test.meta("testID", "settings-general-006").meta({ type: "short", mode: "auth" }
await contextmenu.checkContextMenuActionAvailability("edit", false);
await contextmenu.clearSelection();
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await toolbar.search("photo:true");
await photoviewer.openPhotoViewer("nth", 0);

View File

@@ -50,7 +50,7 @@ test.meta("testID", "components-004").meta({ mode: "public" })("Common: List vie
});
test.meta("testID", "components-005").meta({ type: "short", mode: "public" })("Common: Card view", async (t) => {
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await toolbar.search("photo:true");
await t

View File

@@ -27,7 +27,7 @@ test.meta("testID", "labels-001").meta({ type: "short", mode: "public" })(
await toolbar.search("beacon");
const LabelBeaconUid = await label.getNthLabeltUid(0);
await label.openLabelWithUid(LabelBeaconUid);
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
const PhotoBeaconUid = await photo.getNthPhotoUid("all", 0);
await page.clickCardTitleOfUID(PhotoBeaconUid);
const PhotoKeywords = await photoedit.keywords.value;
@@ -53,7 +53,7 @@ test.meta("testID", "labels-001").meta({ type: "short", mode: "public" })(
await toolbar.search("test");
const LabelTest = await label.getNthLabeltUid(0);
await label.openLabelWithUid(LabelTest);
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await page.clickCardTitleOfUID(PhotoBeaconUid);
await t
.click(photoedit.labelsTab)
@@ -178,7 +178,7 @@ test.meta("testID", "labels-004").meta({ mode: "public" })("Common: Delete label
await menu.openPage("browse");
await toolbar.search("uid:" + FirstPhotoDomeUid);
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await page.clickCardTitleOfUID(FirstPhotoDomeUid);
await t.click(photoedit.labelsTab);

View File

@@ -47,7 +47,7 @@ test.meta("testID", "photos-archive-private-001").meta({ type: "short", mode: "p
/*await toolbar.setFilter("view", "List");
await photo.triggerListViewActions("uid", SecondPhotoUid, "private");
await photo.triggerListViewActions("uid", SecondVideoUid, "private");*/
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await photo.triggerHoverAction("uid", ThirdPhotoUid, "select");
await photo.triggerHoverAction("uid", ThirdVideoUid, "select");
await contextmenu.triggerContextMenuAction("edit", "");

View File

@@ -10,7 +10,7 @@ import Page from "../page-model/page";
import PhotoEdit from "../page-model/photo-edit";
const scroll = ClientFunction((x, y) => window.scrollTo(x, y));
const getcurrentPosition = ClientFunction(() => window.pageYOffset);
const getcurrentPosition = ClientFunction(() => window.scrollY);
fixture`Test photos`.page`${testcafeconfig.url}`;
@@ -23,7 +23,7 @@ const page = new Page();
const photoedit = new PhotoEdit();
test.meta("testID", "photos-001").meta({ mode: "public" })("Common: Scroll to top", async (t) => {
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await t
.expect(Selector("button.is-photo-scroll-top").exists)
@@ -96,7 +96,7 @@ test.meta("testID", "photos-003").meta({ type: "short", mode: "public" })(
.typeText(photoedit.latitude, "9.999", { replace: true })
.typeText(photoedit.longitude, "9.999", { replace: true });
await t.click(photoedit.detailsApply).click(photoedit.detailsClose);
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
const ApproveButtonThirdPhoto = 'div.is-photo[data-uid="' + ThirdPhotoUid + '"] button.action-approve';
await t.click(Selector(ApproveButtonThirdPhoto));
if (t.browser.platform === "mobile") {
@@ -169,7 +169,7 @@ test.meta("testID", "photos-004").meta({ type: "short", mode: "public" })(
);
test.meta("testID", "photos-005").meta({ type: "short", mode: "public" })("Common: Edit photo/video", async (t) => {
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
await page.clickCardTitleOfUID(FirstPhotoUid);
@@ -319,7 +319,7 @@ test.meta("testID", "photos-005").meta({ type: "short", mode: "public" })("Commo
test.meta("testID", "photos-006").meta({ mode: "public" })(
"Multi-Window: Navigate from card view to place",
async (t) => {
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await t.click(page.cardLocation.nth(0));
await t
@@ -395,7 +395,7 @@ test.meta("testID", "photos-007").meta({ mode: "public" })("Common: Mark photos/
test.meta("testID", "photos-008").meta({ mode: "public" })(
"Multi-Window: Navigate from card view to photos taken at the same date",
async (t) => {
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await toolbar.search("flower");
await t.click(page.cardTaken.nth(0));

View File

@@ -0,0 +1,211 @@
import { Selector, ClientFunction } from "testcafe";
import testcafeconfig from "../../testcafeconfig.json";
import Menu from "../page-model/menu";
import Toolbar from "../page-model/toolbar";
import Photo from "../page-model/photo";
import PhotoViewer from "../page-model/photoviewer";
import Page from "../page-model/page";
import PhotoEdit from "../page-model/photo-edit";
import Subject from "../page-model/subject";
import Label from "../page-model/label";
import Library from "../page-model/library";
fixture`Test Keyboard Shortcuts`
.page`${testcafeconfig.url}`;
const menu = new Menu();
const toolbar = new Toolbar();
const photo = new Photo();
const photoviewer = new PhotoViewer();
const page = new Page();
const photoEdit = new PhotoEdit();
const subject = new Subject();
const label = new Label();
const library = new Library();
const triggerKeyPress = ClientFunction((key, code, keyCode, ctrlKey, shiftKey, targetSelector) => {
const target = targetSelector ? document.querySelector(targetSelector) : document;
if (!target) {
console.error("Target element not found for selector:", targetSelector);
return;
}
target.dispatchEvent(new KeyboardEvent('keydown', {
key: key,
code: code,
keyCode: keyCode,
which: keyCode,
bubbles: true,
cancelable: true,
ctrlKey: ctrlKey,
shiftKey: shiftKey,
altKey: false,
metaKey: false
}));
}, {
});
const isFullscreen = ClientFunction(() => !!document.fullscreenElement);
const getcurrentPosition = ClientFunction(() => window.scrollY);
test.meta("testID", "shortcuts-001").meta({ type: "short", mode: "public" })(
"Common: Test General Page Shortcuts",
async (t) => {
await menu.openPage("browse");
await t.expect(toolbar.search1.focused).notOk();
await triggerKeyPress('f', 'KeyF', 70, true, false);
await t.expect(toolbar.search1.focused).ok();
// Test Refresh (Ctrl+R) with scroll restoration
await t.wait(500);
await t.scroll(0, 500); // Scroll down
const initialScrollY = await getcurrentPosition();
await t.expect(initialScrollY).gt(0, "Should have scrolled down before refresh");
await triggerKeyPress('r', 'KeyR', 82, true, false);
await t.wait(2000); // Wait for page to reload
const finalScrollY = await getcurrentPosition();
await t.expect(finalScrollY).eql(initialScrollY, "Scroll position should be restored after refresh");
// Test Upload (Ctrl+U)
await triggerKeyPress('u', 'KeyU', 85, true, false);
await t.expect(Selector(".p-upload-dialog").visible).ok();
await t.pressKey("esc");
await t.expect(Selector(".p-upload-dialog").visible).notOk();
}
);
test.meta("testID", "shortcuts-002").meta({ type: "short", mode: "public" })(
"Common: Test Lightbox Shortcuts",
async (t) => {
await menu.openPage("browse");
await t.navigateTo('/library/videos');
const videoUid = await photo.getNthPhotoUid("all", 0);
await photoviewer.openPhotoViewer("uid", videoUid);
await t.wait(500);
const infoPanelSelector = Selector('div').withText('Information').nth(4);
await t.expect(infoPanelSelector.visible).notOk("Information panel should not be visible initially");
await triggerKeyPress('i', 'KeyI', 73, true, false, 'div.p-lightbox__pswp');
await t.expect(infoPanelSelector.visible).ok("Information panel should be visible after first Ctrl+I");
await triggerKeyPress('i', 'KeyI', 73, true, false, 'div.p-lightbox__pswp');
await t.expect(infoPanelSelector.visible).notOk("Information panel should be hidden after second Ctrl+I");
await triggerKeyPress('m', 'KeyM', 77, true, false, 'div.p-lightbox__pswp');
await t.expect(Selector('.p-lightbox__content').hasClass("is-muted")).ok("Video should be muted after first Ctrl+M");
await triggerKeyPress('m', 'KeyM', 77, true, false, 'div.p-lightbox__pswp');
await t.expect(Selector('.p-lightbox__content').hasClass("is-muted")).notOk("Video should be unmuted after second Ctrl+M");
await triggerKeyPress('s', 'KeyS', 83, true, false, 'div.p-lightbox__pswp');
await t.expect(Selector('.p-lightbox__content').hasClass("slideshow-active")).ok("Slideshow should be active after first Ctrl+S");
await triggerKeyPress('s', 'KeyS', 83, true, false, 'div.p-lightbox__pswp');
await t.expect(Selector('.p-lightbox__content').hasClass("slideshow-active")).notOk("Slideshow should be inactive after second Ctrl+S");
await triggerKeyPress('Escape', 'Escape', 27, false, false, 'div.p-lightbox__pswp');
}
);
test.meta("testID", "shortcuts-003").meta({ type: "short", mode: "public" })(
"Common: Test Lightbox Archive and Download Shortcuts",
async (t) => {
await menu.openPage("browse");
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
await photoviewer.openPhotoViewer("uid", FirstPhotoUid);
await t.expect(photoviewer.viewer.visible).ok();
await t.getBrowserConsoleMessages();
await triggerKeyPress('a', 'KeyA', 65, true, false);
await t.wait(500);
let consoleMessages = await t.getBrowserConsoleMessages();
await t.expect(consoleMessages.log).contains('success: archived', 'Console should contain "success: archived" after Ctrl+A');
await t.getBrowserConsoleMessages();
await triggerKeyPress('d', 'KeyD', 68, true, false);
await t.wait(500);
consoleMessages = await t.getBrowserConsoleMessages();
await t.expect(consoleMessages.log).contains('success: downloading\u2026', 'Console should contain "success: downloading..." after Ctrl+D');
await t.pressKey("esc");
}
);
test.meta("testID", "shortcuts-004").meta({ type: "short", mode: "public" })(
"Common: Test Lightbox Edit, Fullscreen, and Like Shortcuts",
async (t) => {
await menu.openPage("browse");
const FirstPhotoUid = await photo.getNthPhotoUid("image", 0);
await photoviewer.openPhotoViewer("uid", FirstPhotoUid);
await t.wait(500); // Wait for lightbox
// Edit Test
await triggerKeyPress('e', 'KeyE', 69, true, false);
await t.expect(photoEdit.dialog.visible).ok();
await t.pressKey("esc");
await photoviewer.openPhotoViewer("uid", FirstPhotoUid);
await t.wait(500); // Wait for lightbox again
// Fullscreen Test
await triggerKeyPress('f', 'KeyF', 70, true, false, 'div.p-lightbox__pswp');
await t.wait(1000);
await t.expect(isFullscreen()).ok("Browser did not enter fullscreen mode.");
await triggerKeyPress('f', 'KeyF', 70, true, false, 'div.p-lightbox__pswp');
await t.wait(1000);
await t.expect(isFullscreen()).notOk("Browser did not exit fullscreen mode.");
// Like Test
const isLikedInitially = await Selector('.p-lightbox__content').hasClass('is-favorite');
await triggerKeyPress('l', 'KeyL', 76, true, false, 'div.p-lightbox__pswp');
await t.wait(2000); // Wait for potential UI updates
await t.expect(photoviewer.menuButton.exists).ok("Menu button does not exist after Ctrl+L");
const isLikedAfterToggle = await Selector('.p-lightbox__content').hasClass('is-favorite');
if (isLikedInitially) {
await t.expect(isLikedAfterToggle).notOk("Failed to unlike photo after Ctrl+L");
} else {
await t.expect(isLikedAfterToggle).ok("Failed to like photo after Ctrl+L");
}
await t.pressKey("esc");
}
);
test.meta("testID", "shortcuts-005").meta({ type: "short", mode: "public" })(
"Common: Test Expansion Panel and Page-Specific Search Focus",
async (t) => {
await menu.openPage("browse");
await t.wait(500);
await triggerKeyPress('f', 'KeyF', 70, true, true);
await t.wait(500);
await t.expect(Selector(".toolbar-expansion-panel").find('div').withText('All Countries').exists).ok("Expansion panel content ('All Countries') not found after Shift+Ctrl+F");
// Close expansion panel
// TODO: Currently no close functionality implemented
// await triggerKeyPress('f', 'KeyF', 70, true, true); // Toggle it off
// await t.wait(500); // Wait for animation
// await t.expect(Selector(".toolbar-expansion-panel").getAttribute("style")).contains("display: none", "Expansion panel is not hidden (display: none) after second Shift+Ctrl+F");
await menu.openPage("people");
await t.wait(500);
await triggerKeyPress('f', 'KeyF', 70, true, false);
await t.expect(subject.search.focused).ok();
await menu.openPage("labels");
await t.wait(500);
await triggerKeyPress('f', 'KeyF', 70, true, false);
await t.expect(label.search.focused).ok();
await t.navigateTo('/library/errors');
await t.wait(500);
await triggerKeyPress('f', 'KeyF', 70, true, false);
await t.expect(library.searchInput.focused).ok();
}
);

View File

@@ -39,7 +39,7 @@ test.meta("testID", "stacks-001").meta({ type: "short", mode: "public" })(
test.meta("testID", "stacks-002").meta({ type: "short", mode: "public" })("Common: Change primary file", async (t) => {
await toolbar.search("ski");
const SequentialPhotoUid = await photo.getNthPhotoUid("all", 0);
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await page.clickCardTitleOfUID(SequentialPhotoUid);
await t.click(photoedit.filesTab);
const FirstFileName = await Selector("td").withText("Filename").nextSibling(0).innerText;
@@ -61,7 +61,7 @@ test.meta("testID", "stacks-002").meta({ type: "short", mode: "public" })("Commo
test.meta("testID", "stacks-003").meta({ type: "short", mode: "public" })("Common: Ungroup files", async (t) => {
await toolbar.search("group");
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
const PhotoCount = await photo.getPhotoCount("all");
const SequentialPhotoUid = await photo.getNthPhotoUid("all", 0);
@@ -69,7 +69,7 @@ test.meta("testID", "stacks-003").meta({ type: "short", mode: "public" })("Commo
await menu.openPage("stacks");
await photo.checkHoverActionAvailability("uid", SequentialPhotoUid, "open", true);
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
await page.clickCardTitleOfUID(SequentialPhotoUid);
await t
.click(photoedit.filesTab)
@@ -101,7 +101,7 @@ test.meta("testID", "stacks-004").meta({ mode: "public" })("Common: Delete non p
.wait(10000);
await menu.openPage("browse");
await toolbar.search("pizza");
await toolbar.setFilter("view", "Cards");
await t.click(toolbar.cardsViewAction);
const PhotoCount = await photo.getPhotoCount("all");
const PhotoUid = await photo.getNthPhotoUid("all", 0);

View File

@@ -40,7 +40,7 @@ test.meta("testID", "states-001").meta({ mode: "public" })("Common: Update state
await t
.typeText(albumdialog.title, "Wonderland", { replace: true })
.typeText(albumdialog.location, "Earth", { replace: true })
.typeText(albumdialog.description, "We love earth")
.typeText(albumdialog.description, "We love earth", { replace: true })
.typeText(albumdialog.category, "Mountains")
.pressKey("enter")
.click(albumdialog.dialogSave);

View File

@@ -1,7 +1,9 @@
import { Selector, t } from "testcafe";
export default class Page {
constructor() {}
constructor() {
this.search = Selector(".input-search input");
}
async getNthLabeltUid(nth) {
const NthLabel = await Selector("div.is-label").nth(nth).getAttribute("data-uid");

View File

@@ -11,5 +11,6 @@ export default class Page {
this.logsTab = Selector("#tab-library_logs", { timeout: 15000 });
this.moveCheckbox = Selector("label").withText("Move Files");
this.completeRescanCheckbox = Selector("label").withText("Complete Rescan");
this.searchInput = Selector(".input-search input");
}
}

View File

@@ -1,13 +1,17 @@
import { Selector, t } from "testcafe";
export default class Page {
constructor() {}
constructor() {
this.navDrawer = Selector(".v-navigation-drawer");
this.expandButton = Selector("div.nav-expand i");
this.expandButtonContainer = Selector("div.nav-expand");
}
async openNav() {
if (await Selector("div.nav-expand").visible) {
await t.click(Selector("div.nav-expand i"));
} else if (await Selector("div.nav-expand").visible) {
await t.click(Selector("div.nav-expand i"));
if (!(await this.navDrawer.visible)) {
if (await this.expandButtonContainer.visible) {
await t.click(this.expandButton.with({ boundTestRun: t }));
}
}
}

View File

@@ -30,6 +30,7 @@ export default class Page {
this.passcodeInput = Selector(".input-code input", { timeout: 7000 });
this.togglePasswordMode = Selector(".v-field__append-inner", { timeout: 7000 });
this.loginAction = Selector(".action-confirm", { timeout: 7000 });
this.snackbar = Selector(".v-snackbar__content");
}
async login(username, password) {

View File

@@ -2,6 +2,7 @@ import { Selector, t } from "testcafe";
export default class Page {
constructor() {
this.dialog = Selector("div.v-dialog");
this.dialogClose = Selector("div.v-dialog button.action-close", { timeout: 15000 });
this.dialogNext = Selector("div.v-dialog button.action-next", { timeout: 15000 });
this.dialogPrevious = Selector("div.v-dialog button.action-previous", { timeout: 15000 });

View File

@@ -7,6 +7,13 @@ export default class Page {
this.countries = Selector("div.p-countries-select", { timeout: 15000 });
this.time = Selector("div.p-time-select", { timeout: 15000 });
this.search1 = Selector("div.input-search input", { timeout: 15000 });
this.menuButton = Selector("button.action-menu__btn", { timeout: 15000 });
this.viewer = Selector("div.p-lightbox__pswp", { timeout: 15000 });
this.caption = Selector("div.pswp__caption__center", { timeout: 5000 });
this.muteButton = Selector("button.pswp__button--mute", { timeout: 5000 });
this.playButton = Selector('[class^="pswp__button pswp__button--slideshow-toggle pswp__"]', { timeout: 5000 });
this.favoriteOnIcon = Selector("button.action-favorite i.icon-favorite", { timeout: 5000 });
this.favoriteOffIcon = Selector("button.action-favorite i.icon-favorite-border", { timeout: 5000 });
}
async openPhotoViewer(mode, uidOrNth) {

View File

@@ -6,6 +6,7 @@ export default class Page {
this.newTab = Selector("#tab-people_faces", { timeout: 15000 });
this.showAllNewButton = Selector('a[href="/all?q=face%3Anew"]');
this.subjectName = Selector("a.is-subject div.meta-title");
this.search = Selector(".v-window-item--active .input-search input");
}
async addNameToFace(id, name) {

29
go.mod
View File

@@ -12,10 +12,10 @@ require (
github.com/dsoprea/go-tiff-image-structure/v2 v2.0.0-20221003165014-8ecc4f52edca
github.com/dustin/go-humanize v1.0.1
github.com/esimov/pigo v1.4.6
github.com/gin-contrib/gzip v1.2.2
github.com/gin-contrib/gzip v1.2.3
github.com/gin-gonic/gin v1.10.0
github.com/golang/geo v0.0.0-20250401183444-7b99cb294169
github.com/google/open-location-code/go v0.0.0-20250307090349-1695db3c3b15
github.com/golang/geo v0.0.0-20250411042641-97e19c1a7ce7
github.com/google/open-location-code/go v0.0.0-20250413133937-894dfd253334
github.com/gorilla/websocket v1.5.3
github.com/gosimple/slug v1.15.0
github.com/jinzhu/gorm v1.9.16
@@ -40,15 +40,15 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/crypto v0.36.0
golang.org/x/net v0.38.0
golang.org/x/crypto v0.37.0
golang.org/x/net v0.39.0
gonum.org/v1/gonum v0.16.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
golang.org/x/image v0.25.0
golang.org/x/image v0.26.0
)
require github.com/olekukonko/tablewriter v0.0.5
@@ -60,20 +60,20 @@ require github.com/chzyer/readline v1.5.1 // indirect
require github.com/gabriel-vasile/mimetype v1.4.8
require (
golang.org/x/sync v0.12.0
golang.org/x/sync v0.13.0
golang.org/x/time v0.11.0
)
require github.com/go-ldap/ldap/v3 v3.4.10
require (
github.com/prometheus/client_golang v1.21.1
github.com/prometheus/client_golang v1.22.0
github.com/prometheus/common v0.63.0
)
require github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
require golang.org/x/text v0.23.0
require golang.org/x/text v0.24.0
require (
github.com/IGLOU-EU/go-wildcard v1.0.3
@@ -88,7 +88,7 @@ require (
github.com/urfave/cli/v2 v2.27.6
github.com/zitadel/oidc/v3 v3.37.0
golang.org/x/mod v0.24.0
golang.org/x/sys v0.31.0
golang.org/x/sys v0.32.0
)
require (
@@ -104,7 +104,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
@@ -123,7 +123,6 @@ require (
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mandykoh/go-parallel v0.1.0 // indirect
@@ -159,10 +158,10 @@ require (
github.com/abema/go-mp4 v1.4.1
github.com/bytedance/sonic v1.13.2 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-playground/validator/v10 v10.25.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sunfish-shogi/bufseekio v0.1.0
golang.org/x/arch v0.15.0 // indirect
golang.org/x/arch v0.16.0 // indirect
)
require (

60
go.sum
View File

@@ -123,10 +123,10 @@ github.com/esimov/pigo v1.4.6/go.mod h1:uqj9Y3+3IRYhFK071rxz1QYq0ePhA6+R9jrUZavi
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/gzip v1.2.2 h1:iUU/EYCM8ENfkjmZaVrxbjF/ZC267Iqv5S0MMCMEliI=
github.com/gin-contrib/gzip v1.2.2/go.mod h1:C1a5cacjlDsS20cKnHlZRCPUu57D3qH6B2pV0rl+Y/s=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U=
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
@@ -166,8 +166,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
@@ -182,8 +182,8 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250401183444-7b99cb294169 h1:eUHA2nS2x90E5bb1BVW9iKztbK68XOPavDz/AUXiXZw=
github.com/golang/geo v0.0.0-20250401183444-7b99cb294169/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/geo v0.0.0-20250411042641-97e19c1a7ce7 h1:HNykSFq2QowNxC/zZc1IEbRuj30sMiY4aCSLb4EK/zA=
github.com/golang/geo v0.0.0-20250411042641-97e19c1a7ce7/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -209,8 +209,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/open-location-code/go v0.0.0-20250307090349-1695db3c3b15 h1:KvSp6pj8k8MgLIAUbeJmzK6UNCxIFHlD5gwUsWI7WLc=
github.com/google/open-location-code/go v0.0.0-20250307090349-1695db3c3b15/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/open-location-code/go v0.0.0-20250413133937-894dfd253334 h1:04WkMzb+P8Zcx6MeGWurTcZVj1GJy3VHlXfopM81rY8=
github.com/google/open-location-code/go v0.0.0-20250413133937-894dfd253334/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -273,8 +273,6 @@ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALr
github.com/karrick/godirwalk v1.17.0 h1:b4kY7nqDdioR/6qnbHQyDvmA17u5G1cZ6J+CZXwSWoI=
github.com/karrick/godirwalk v1.17.0/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -338,14 +336,14 @@ github.com/paulmach/go.geojson v1.5.0 h1:7mhpMK89SQdHFcEGomT7/LuJhwhEgfmpWYVlVmL
github.com/paulmach/go.geojson v1.5.0/go.mod h1:DgdUy2rRVDDVgKqrjMe2vZAHMfhDTrjVKt3LmHIXGbU=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
@@ -374,7 +372,6 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -382,7 +379,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
@@ -440,8 +436,8 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go4.org v0.0.0-20200411211856-f5505b9728dd/go.mod h1:CIiUVy99QCPfoE13bO4EZaz5GZMZXMSBGhxRdsvzbkg=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -455,8 +451,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -471,8 +467,8 @@ golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+o
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -527,8 +523,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -548,8 +544,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -584,8 +580,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20191110171634-ad39bd3f0407/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -609,8 +605,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=

View File

@@ -44,6 +44,17 @@ func LocationLabel(name string, uncertainty int) Label {
}
// Title returns a formatted label title as string.
func (l Label) Title() string {
func (l *Label) Title() string {
return txt.Title(txt.Clip(l.Name, txt.ClipDefault))
}
// Confidence returns a matching confidence in percent.
func (l *Label) Confidence() float32 {
if l.Uncertainty > 100 {
return 0
} else if l.Uncertainty < 0 {
return 1
} else {
return 1 - float32(l.Uncertainty)/100
}
}

View File

@@ -0,0 +1,223 @@
package classify
import (
"bytes"
"fmt"
"math"
"os"
"path"
"runtime/debug"
"sort"
"strings"
"sync"
"github.com/disintegration/imaging"
tf "github.com/wamuir/graft/tensorflow"
"github.com/photoprism/photoprism/internal/ai/tensorflow"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/http/scheme"
)
// Model represents a TensorFlow classification model.
type Model struct {
model *tf.SavedModel
modelPath string
assetsPath string
resolution int
modelTags []string
labels []string
disabled bool
mutex sync.Mutex
}
// NewModel returns new TensorFlow classification model instance.
func NewModel(assetsPath, modelPath string, resolution int, modelTags []string, disabled bool) *Model {
return &Model{assetsPath: assetsPath, modelPath: modelPath, resolution: resolution, modelTags: modelTags, disabled: disabled}
}
// NewNasnet returns new Nasnet TensorFlow classification model instance.
func NewNasnet(assetsPath string, disabled bool) *Model {
return NewModel(assetsPath, "nasnet", 224, []string{"photoprism"}, disabled)
}
// Init initialises tensorflow models if not disabled
func (m *Model) Init() (err error) {
if m.disabled {
return nil
}
return m.loadModel()
}
// File returns matching labels for a local jpeg file.
func (m *Model) File(fileName string, confidenceThreshold int) (result Labels, err error) {
if m.disabled {
return nil, nil
}
var data []byte
if data, err = os.ReadFile(fileName); err != nil {
return nil, err
}
return m.Run(data, confidenceThreshold)
}
// Url returns matching labels for a remote jpeg file.
func (m *Model) Url(imgUrl string, confidenceThreshold int) (result Labels, err error) {
if m.disabled {
return nil, nil
}
var data []byte
if data, err = media.ReadUrl(imgUrl, scheme.HttpsData); err != nil {
return nil, err
}
return m.Run(data, confidenceThreshold)
}
// Run returns matching labels for the specified JPEG image.
func (m *Model) Run(img []byte, confidenceThreshold int) (result Labels, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("classify: %s (inference panic)\nstack: %s", r, debug.Stack())
}
}()
if m.disabled {
return result, nil
}
if loadErr := m.loadModel(); loadErr != nil {
return nil, loadErr
}
// Create input tensor from image.
tensor, err := m.createTensor(img)
if err != nil {
return nil, err
}
// Run inference.
output, err := m.model.Session.Run(
map[tf.Output]*tf.Tensor{
m.model.Graph.Operation("input_1").Output(0): tensor,
},
[]tf.Output{
m.model.Graph.Operation("predictions/Softmax").Output(0),
},
nil)
if err != nil {
return result, fmt.Errorf("classify: %s (run inference)", err.Error())
}
if len(output) < 1 {
return result, fmt.Errorf("classify: inference failed, no output")
}
// Return best labels
result = m.bestLabels(output[0].Value().([][]float32)[0], confidenceThreshold)
if len(result) > 0 {
log.Tracef("classify: image classified as %+v", result)
} else {
result = Labels{}
}
return result, nil
}
func (m *Model) loadLabels(modelPath string) (err error) {
m.labels, err = tensorflow.LoadLabels(modelPath)
return err
}
// ModelLoaded tests if the TensorFlow model is loaded.
func (m *Model) ModelLoaded() bool {
return m.model != nil
}
func (m *Model) loadModel() (err error) {
// Use mutex to prevent the model from being loaded and
// initialized twice by different indexing workers.
m.mutex.Lock()
defer m.mutex.Unlock()
if m.ModelLoaded() {
return nil
}
modelPath := path.Join(m.assetsPath, m.modelPath)
m.model, err = tensorflow.SavedModel(modelPath, m.modelTags)
return m.loadLabels(modelPath)
}
// bestLabels returns the best 5 labels (if enough high probability labels) from the prediction of the model
func (m *Model) bestLabels(probabilities []float32, confidenceThreshold int) Labels {
var result Labels
for i, p := range probabilities {
if i >= len(m.labels) {
// break if probabilities and labels does not match
break
}
confidence := int(math.Round(float64(p * 100)))
// discard labels with low probabilities
if confidence < confidenceThreshold {
continue
}
labelText := strings.ToLower(m.labels[i])
rule, _ := Rules.Find(labelText)
// discard labels that don't met the threshold
if p < rule.Threshold {
continue
}
// Get rule label name instead of t.labels name if it exists
if rule.Label != "" {
labelText = rule.Label
}
labelText = strings.TrimSpace(labelText)
result = append(result, Label{Name: labelText, Source: SrcImage, Uncertainty: 100 - confidence, Priority: rule.Priority, Categories: rule.Categories})
}
// Sort by probability
sort.Sort(result)
// Return the best labels only.
if l := len(result); l < 5 {
return result[:l]
} else {
return result[:5]
}
}
// createTensor converts bytes jpeg image in a tensor object required as tensorflow model input
func (m *Model) createTensor(image []byte) (*tf.Tensor, error) {
img, err := imaging.Decode(bytes.NewReader(image), imaging.AutoOrientation(true))
if err != nil {
return nil, err
}
// Resize the image only if its resolution does not match the model.
if img.Bounds().Dx() != m.resolution || img.Bounds().Dy() != m.resolution {
img = imaging.Fill(img, m.resolution, m.resolution, imaging.Center, imaging.Lanczos)
}
return tensorflow.Image(img, m.resolution)
}

View File

@@ -15,12 +15,11 @@ var assetsPath = fs.Abs("../../../assets")
var modelPath = assetsPath + "/nasnet"
var examplesPath = assetsPath + "/examples"
var once sync.Once
var testInstance *TensorFlow
var testInstance *Model
// NewTest returns a new TensorFlow test instance.
func NewTest(t *testing.T) *TensorFlow {
func NewModelTest(t *testing.T) *Model {
once.Do(func() {
testInstance = New(assetsPath, false)
testInstance = NewNasnet(assetsPath, false)
if err := testInstance.loadModel(); err != nil {
t.Fatal(err)
}
@@ -29,39 +28,81 @@ func NewTest(t *testing.T) *TensorFlow {
return testInstance
}
func TestTensorFlow_LabelsFromFile(t *testing.T) {
func TestModel_LabelsFromFile(t *testing.T) {
t.Run("chameleon_lime.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
result, err := tensorFlow.File(examplesPath + "/chameleon_lime.jpg")
assert.Nil(t, err)
if err != nil {
t.Fatal(err)
}
tensorFlow := NewModelTest(t)
result, err := tensorFlow.File(examplesPath+"/chameleon_lime.jpg", 10)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.IsType(t, Labels{}, result)
assert.Equal(t, 1, len(result))
t.Log(result)
if len(result) > 0 {
t.Logf("result: %#v", result[0])
assert.Equal(t, "chameleon", result[0].Name)
assert.Equal(t, "chameleon", result[0].Name)
assert.Equal(t, 7, result[0].Uncertainty)
}
})
t.Run("cat_224.jpeg", func(t *testing.T) {
tensorFlow := NewModelTest(t)
result, err := tensorFlow.File(examplesPath+"/cat_224.jpeg", 10)
assert.Equal(t, 7, result[0].Uncertainty)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.IsType(t, Labels{}, result)
assert.Equal(t, 1, len(result))
if len(result) > 0 {
assert.Equal(t, "cat", result[0].Name)
assert.Equal(t, 59, result[0].Uncertainty)
}
})
t.Run("cat_720.jpeg", func(t *testing.T) {
tensorFlow := NewModelTest(t)
result, err := tensorFlow.File(examplesPath+"/cat_720.jpeg", 10)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.IsType(t, Labels{}, result)
assert.Equal(t, 3, len(result))
// t.Logf("labels: %#v", result)
if len(result) > 0 {
assert.Equal(t, "cat", result[0].Name)
assert.Equal(t, 60, result[0].Uncertainty)
}
})
t.Run("green.jpg", func(t *testing.T) {
tensorFlow := NewModelTest(t)
result, err := tensorFlow.File(examplesPath+"/green.jpg", 10)
t.Logf("labels: %#v", result)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.IsType(t, Labels{}, result)
assert.Equal(t, 1, len(result))
if len(result) > 0 {
assert.Equal(t, "outdoor", result[0].Name)
assert.Equal(t, 70, result[0].Uncertainty)
}
})
t.Run("not existing file", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
result, err := tensorFlow.File(examplesPath + "/notexisting.jpg")
result, err := tensorFlow.File(examplesPath+"/notexisting.jpg", 10)
assert.Contains(t, err.Error(), "no such file or directory")
assert.Empty(t, result)
})
t.Run("disabled true", func(t *testing.T) {
tensorFlow := New(assetsPath, true)
tensorFlow := NewNasnet(assetsPath, true)
result, err := tensorFlow.File(examplesPath + "/chameleon_lime.jpg")
result, err := tensorFlow.File(examplesPath+"/chameleon_lime.jpg", 10)
assert.Nil(t, err)
if err != nil {
@@ -76,18 +117,18 @@ func TestTensorFlow_LabelsFromFile(t *testing.T) {
})
}
func TestTensorFlow_Labels(t *testing.T) {
func TestModel_Run(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
t.Run("chameleon_lime.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
if imageBuffer, err := os.ReadFile(examplesPath + "/chameleon_lime.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Run(imageBuffer, 10)
t.Log(result)
@@ -106,12 +147,12 @@ func TestTensorFlow_Labels(t *testing.T) {
}
})
t.Run("dog_orange.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
if imageBuffer, err := os.ReadFile(examplesPath + "/dog_orange.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Run(imageBuffer, 10)
t.Log(result)
@@ -130,23 +171,23 @@ func TestTensorFlow_Labels(t *testing.T) {
}
})
t.Run("Random.docx", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
if imageBuffer, err := os.ReadFile(examplesPath + "/Random.docx"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Run(imageBuffer, 10)
assert.Empty(t, result)
assert.Error(t, err)
}
})
t.Run("6720px_white.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
tensorFlow := NewModelTest(t)
if imageBuffer, err := os.ReadFile(examplesPath + "/6720px_white.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Run(imageBuffer, 10)
if err != nil {
t.Fatal(err)
@@ -156,12 +197,12 @@ func TestTensorFlow_Labels(t *testing.T) {
}
})
t.Run("disabled true", func(t *testing.T) {
tensorFlow := New(assetsPath, true)
tensorFlow := NewNasnet(assetsPath, true)
if imageBuffer, err := os.ReadFile(examplesPath + "/dog_orange.jpg"); err != nil {
t.Error(err)
} else {
result, err := tensorFlow.Labels(imageBuffer)
result, err := tensorFlow.Run(imageBuffer, 10)
t.Log(result)
@@ -174,34 +215,36 @@ func TestTensorFlow_Labels(t *testing.T) {
})
}
func TestTensorFlow_LoadModel(t *testing.T) {
func TestModel_LoadModel(t *testing.T) {
t.Run("model loaded", func(t *testing.T) {
tf := NewTest(t)
tf := NewModelTest(t)
assert.True(t, tf.ModelLoaded())
})
t.Run("model path does not exist", func(t *testing.T) {
tensorFlow := New(assetsPath+"foo", false)
if err := tensorFlow.loadModel(); err != nil {
assert.Contains(t, err.Error(), "Could not find SavedModel")
} else {
t.Fatal("err should NOT be nil")
tensorFlow := NewNasnet(assetsPath+"foo", false)
err := tensorFlow.loadModel()
if err != nil {
assert.Contains(t, err.Error(), "no such file or directory")
}
assert.Error(t, err)
})
}
func TestTensorFlow_BestLabels(t *testing.T) {
func TestModel_BestLabels(t *testing.T) {
t.Run("labels not loaded", func(t *testing.T) {
tensorFlow := New(assetsPath, false)
tensorFlow := NewNasnet(assetsPath, false)
p := make([]float32, 1000)
p[666] = 0.5
result := tensorFlow.bestLabels(p)
result := tensorFlow.bestLabels(p, 10)
assert.Empty(t, result)
})
t.Run("labels loaded", func(t *testing.T) {
tensorFlow := New(assetsPath, false)
tensorFlow := NewNasnet(assetsPath, false)
if err := tensorFlow.loadLabels(modelPath); err != nil {
t.Fatal(err)
@@ -212,42 +255,10 @@ func TestTensorFlow_BestLabels(t *testing.T) {
p[8] = 0.7
p[1] = 0.5
result := tensorFlow.bestLabels(p)
result := tensorFlow.bestLabels(p, 10)
assert.Equal(t, "chicken", result[0].Name)
assert.Equal(t, "bird", result[0].Categories[0])
assert.Equal(t, "image", result[0].Source)
t.Log(result)
})
}
func TestTensorFlow_MakeTensor(t *testing.T) {
t.Run("cat_brown.jpg", func(t *testing.T) {
tensorFlow := NewTest(t)
imageBuffer, err := os.ReadFile(examplesPath + "/cat_brown.jpg")
if err != nil {
t.Fatal(err)
}
result, err := tensorFlow.createTensor(imageBuffer, "jpeg")
assert.Equal(t, tensorflow.DataType(0x1), result.DataType())
assert.Equal(t, int64(1), result.Shape()[0])
assert.Equal(t, int64(224), result.Shape()[2])
})
t.Run("Random.docx", func(t *testing.T) {
tensorFlow := NewTest(t)
imageBuffer, err := os.ReadFile(examplesPath + "/Random.docx")
assert.Nil(t, err)
result, err := tensorFlow.createTensor(imageBuffer, "jpeg")
assert.Empty(t, result)
assert.EqualError(t, err, "image: unknown format")
})
}
func Test_convertValue(t *testing.T) {
result := convertValue(uint32(98765432))
assert.Equal(t, float32(3024.898), result)
}

View File

@@ -1,259 +0,0 @@
package classify
import (
"bufio"
"bytes"
"fmt"
"image"
"math"
"os"
"path"
"path/filepath"
"runtime/debug"
"sort"
"strings"
"github.com/disintegration/imaging"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
"github.com/photoprism/photoprism/pkg/clean"
)
// TensorFlow is a wrapper for tensorflow low-level API.
type TensorFlow struct {
model *tf.SavedModel
modelsPath string
disabled bool
modelName string
modelTags []string
labels []string
}
// New returns new TensorFlow instance with Nasnet model.
func New(modelsPath string, disabled bool) *TensorFlow {
return &TensorFlow{modelsPath: modelsPath, disabled: disabled, modelName: "nasnet", modelTags: []string{"photoprism"}}
}
// Init initialises tensorflow models if not disabled
func (t *TensorFlow) Init() (err error) {
if t.disabled {
return nil
}
return t.loadModel()
}
// File returns matching labels for a jpeg media file.
func (t *TensorFlow) File(filename string) (result Labels, err error) {
if t.disabled {
return result, nil
}
imageBuffer, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
return t.Labels(imageBuffer)
}
// Labels returns matching labels for a jpeg media string.
func (t *TensorFlow) Labels(img []byte) (result Labels, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("classify: %s (inference panic)\nstack: %s", r, debug.Stack())
}
}()
if t.disabled {
return result, nil
}
if err := t.loadModel(); err != nil {
return nil, err
}
// Create tensor from image.
tensor, err := t.createTensor(img, "jpeg")
if err != nil {
return nil, err
}
// Run inference.
output, err := t.model.Session.Run(
map[tf.Output]*tf.Tensor{
t.model.Graph.Operation("input_1").Output(0): tensor,
},
[]tf.Output{
t.model.Graph.Operation("predictions/Softmax").Output(0),
},
nil)
if err != nil {
return result, fmt.Errorf("classify: %s (run inference)", err.Error())
}
if len(output) < 1 {
return result, fmt.Errorf("classify: inference failed, no output")
}
// Return best labels
result = t.bestLabels(output[0].Value().([][]float32)[0])
if len(result) > 0 {
log.Tracef("classify: image classified as %+v", result)
}
return result, nil
}
func (t *TensorFlow) loadLabels(path string) error {
modelLabels := path + "/labels.txt"
log.Infof("classify: loading labels from labels.txt")
// Load labels
f, err := os.Open(modelLabels)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
// Labels are separated by newlines
for scanner.Scan() {
t.labels = append(t.labels, scanner.Text())
}
if err := scanner.Err(); err != nil {
return err
}
return nil
}
// ModelLoaded tests if the TensorFlow model is loaded.
func (t *TensorFlow) ModelLoaded() bool {
return t.model != nil
}
func (t *TensorFlow) loadModel() error {
if t.ModelLoaded() {
return nil
}
modelPath := path.Join(t.modelsPath, t.modelName)
log.Infof("classify: loading %s", clean.Log(filepath.Base(modelPath)))
// Load model
model, err := tf.LoadSavedModel(modelPath, t.modelTags, nil)
if err != nil {
return err
}
t.model = model
return t.loadLabels(modelPath)
}
// bestLabels returns the best 5 labels (if enough high probability labels) from the prediction of the model
func (t *TensorFlow) bestLabels(probabilities []float32) Labels {
var result Labels
for i, p := range probabilities {
if i >= len(t.labels) {
// break if probabilities and labels does not match
break
}
// discard labels with low probabilities
if p < 0.1 {
continue
}
labelText := strings.ToLower(t.labels[i])
rule, _ := Rules.Find(labelText)
// discard labels that don't met the threshold
if p < rule.Threshold {
continue
}
// Get rule label name instead of t.labels name if it exists
if rule.Label != "" {
labelText = rule.Label
}
labelText = strings.TrimSpace(labelText)
uncertainty := 100 - int(math.Round(float64(p*100)))
result = append(result, Label{Name: labelText, Source: SrcImage, Uncertainty: uncertainty, Priority: rule.Priority, Categories: rule.Categories})
}
// Sort by probability
sort.Sort(result)
// Return the best labels only.
if l := len(result); l < 5 {
return result[:l]
} else {
return result[:5]
}
}
// createTensor converts bytes jpeg image in a tensor object required as tensorflow model input
func (t *TensorFlow) createTensor(image []byte, imageFormat string) (*tf.Tensor, error) {
img, err := imaging.Decode(bytes.NewReader(image), imaging.AutoOrientation(true))
if err != nil {
return nil, err
}
width, height := 224, 224
img = imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos)
return imageToTensor(img, width, height)
}
func imageToTensor(img image.Image, imageHeight, imageWidth int) (tfTensor *tf.Tensor, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("classify: %s (panic)\nstack: %s", r, debug.Stack())
}
}()
if imageHeight <= 0 || imageWidth <= 0 {
return tfTensor, fmt.Errorf("classify: image width and height must be > 0")
}
var tfImage [1][][][3]float32
for j := 0; j < imageHeight; j++ {
tfImage[0] = append(tfImage[0], make([][3]float32, imageWidth))
}
for i := 0; i < imageWidth; i++ {
for j := 0; j < imageHeight; j++ {
r, g, b, _ := img.At(i, j).RGBA()
tfImage[0][j][i][0] = convertValue(r)
tfImage[0][j][i][1] = convertValue(g)
tfImage[0][j][i][2] = convertValue(b)
}
}
return tf.NewTensor(tfImage)
}
func convertValue(value uint32) float32 {
return (float32(value>>8) - float32(127.5)) / float32(127.5)
}

View File

@@ -62,7 +62,7 @@ func (m Embedding) Magnitude() float64 {
return m.Dist(NullEmbedding)
}
// JSON returns the face embedding as JSON bytes.
// JSON returns the face embedding as JSON-encoded bytes.
func (m Embedding) JSON() []byte {
var noResult = []byte("")

View File

@@ -109,7 +109,7 @@ func (embeddings Embeddings) Dist(other Embedding) (dist float64) {
return dist
}
// JSON returns the embeddings as JSON bytes.
// JSON returns the embeddings as JSON-encoded bytes.
func (embeddings Embeddings) JSON() []byte {
var noResult = []byte("")

190
internal/ai/face/model.go Normal file
View File

@@ -0,0 +1,190 @@
package face
import (
"fmt"
"image"
"path"
"path/filepath"
"runtime/debug"
"sync"
"github.com/disintegration/imaging"
tf "github.com/wamuir/graft/tensorflow"
"github.com/photoprism/photoprism/internal/thumb/crop"
"github.com/photoprism/photoprism/pkg/clean"
)
// Model is a wrapper for the TensorFlow Facenet model.
type Model struct {
model *tf.SavedModel
modelName string
modelPath string
cachePath string
resolution int
modelTags []string
disabled bool
mutex sync.Mutex
}
// NewModel returns a new TensorFlow Facenet instance.
func NewModel(modelPath, cachePath string, resolution int, tags []string, disabled bool) *Model {
if resolution == 0 {
resolution = CropSize.Width
}
if len(tags) == 0 {
tags = []string{"serve"}
}
return &Model{modelPath: modelPath, cachePath: cachePath, resolution: resolution, modelTags: tags, disabled: disabled}
}
// Detect runs the detection and facenet algorithms over the provided source image.
func (m *Model) Detect(fileName string, minSize int, cacheCrop bool, expected int) (faces Faces, err error) {
faces, err = Detect(fileName, false, minSize)
if err != nil {
return faces, err
}
// Skip FaceNet?
if m.disabled {
return faces, nil
} else if c := len(faces); c == 0 || expected > 0 && c == expected {
return faces, nil
}
err = m.loadModel()
if err != nil {
return faces, err
}
for i, f := range faces {
if f.Area.Col == 0 && f.Area.Row == 0 {
continue
}
if img, _, imgErr := crop.ImageFromThumb(fileName, f.CropArea(), CropSize, cacheCrop); imgErr != nil {
log.Errorf("faces: failed to decode image: %s", imgErr)
} else if embeddings := m.Run(img); !embeddings.Empty() {
faces[i].Embeddings = embeddings
}
}
return faces, nil
}
// Init initialises tensorflow models if not disabled
func (m *Model) Init() (err error) {
if m.disabled {
return nil
}
return m.loadModel()
}
// ModelLoaded tests if the TensorFlow model is loaded.
func (m *Model) ModelLoaded() bool {
return m.model != nil
}
// loadModel loads the TensorFlow model.
func (m *Model) loadModel() error {
// Use mutex to prevent the model from being loaded and
// initialized twice by different indexing workers.
m.mutex.Lock()
defer m.mutex.Unlock()
if m.ModelLoaded() {
return nil
}
modelPath := path.Join(m.modelPath)
log.Infof("faces: loading %s", clean.Log(filepath.Base(modelPath)))
// Load model
model, err := tf.LoadSavedModel(modelPath, m.modelTags, nil)
if err != nil {
return err
}
m.model = model
return nil
}
// Run returns the face embeddings for an image.
func (m *Model) Run(img image.Image) Embeddings {
// Create input tensor from image.
tensor, err := imageToTensor(img, m.resolution)
if err != nil {
log.Errorf("faces: failed to convert image to tensor: %s", err)
}
// TODO: pre-whiten image as in facenet
trainPhaseBoolTensor, err := tf.NewTensor(false)
output, err := m.model.Session.Run(
map[tf.Output]*tf.Tensor{
m.model.Graph.Operation("input").Output(0): tensor,
m.model.Graph.Operation("phase_train").Output(0): trainPhaseBoolTensor,
},
[]tf.Output{
m.model.Graph.Operation("embeddings").Output(0),
},
nil)
if err != nil {
log.Errorf("faces: %s", err)
}
if len(output) < 1 {
log.Errorf("faces: inference failed, no output")
} else {
return NewEmbeddings(output[0].Value().([][]float32))
}
return nil
}
func imageToTensor(img image.Image, resolution int) (tfTensor *tf.Tensor, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("faces: %s (panic)\nstack: %s", r, debug.Stack())
}
}()
if resolution <= 0 {
return tfTensor, fmt.Errorf("faces: invalid model resolution")
}
// Resize the image only if its resolution does not match the model.
if img.Bounds().Dx() != resolution || img.Bounds().Dy() != resolution {
img = imaging.Fill(img, resolution, resolution, imaging.Center, imaging.Lanczos)
}
var tfImage [1][][][3]float32
for j := 0; j < resolution; j++ {
tfImage[0] = append(tfImage[0], make([][3]float32, resolution))
}
for i := 0; i < resolution; i++ {
for j := 0; j < resolution; j++ {
r, g, b, _ := img.At(i, j).RGBA()
tfImage[0][j][i][0] = convertValue(r)
tfImage[0][j][i][1] = convertValue(g)
tfImage[0][j][i][2] = convertValue(b)
}
}
return tf.NewTensor(tfImage)
}
func convertValue(value uint32) float32 {
return (float32(value>>8) - float32(127.5)) / float32(127.5)
}

View File

@@ -54,7 +54,7 @@ func TestNet(t *testing.T) {
var embeddings = make(Embeddings, 11)
faceNet := NewNet(modelPath, "testdata/cache", false)
faceNet := NewModel(modelPath, "testdata/cache", 160, []string{"serve"}, false)
if err := fastwalk.Walk("testdata", func(fileName string, info os.FileMode) error {
if info.IsDir() || filepath.Base(filepath.Dir(fileName)) != "testdata" {

View File

@@ -1,165 +0,0 @@
package face
import (
"fmt"
"image"
"path"
"path/filepath"
"runtime/debug"
"sync"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
"github.com/photoprism/photoprism/internal/thumb/crop"
"github.com/photoprism/photoprism/pkg/clean"
)
// Net is a wrapper for the TensorFlow Facenet model.
type Net struct {
model *tf.SavedModel
modelPath string
cachePath string
disabled bool
modelName string
modelTags []string
mutex sync.Mutex
}
// NewNet returns a new TensorFlow Facenet instance.
func NewNet(modelPath, cachePath string, disabled bool) *Net {
return &Net{modelPath: modelPath, cachePath: cachePath, disabled: disabled, modelTags: []string{"serve"}}
}
// Detect runs the detection and facenet algorithms over the provided source image.
func (t *Net) Detect(fileName string, minSize int, cacheCrop bool, expected int) (faces Faces, err error) {
faces, err = Detect(fileName, false, minSize)
if err != nil {
return faces, err
}
// Skip FaceNet?
if t.disabled {
return faces, nil
} else if c := len(faces); c == 0 || expected > 0 && c == expected {
return faces, nil
}
err = t.loadModel()
if err != nil {
return faces, err
}
for i, f := range faces {
if f.Area.Col == 0 && f.Area.Row == 0 {
continue
}
if img, err := crop.ImageFromThumb(fileName, f.CropArea(), CropSize, cacheCrop); err != nil {
log.Errorf("faces: failed to decode image: %s", err)
} else if embeddings := t.getEmbeddings(img); !embeddings.Empty() {
faces[i].Embeddings = embeddings
}
}
return faces, nil
}
// ModelLoaded tests if the TensorFlow model is loaded.
func (t *Net) ModelLoaded() bool {
return t.model != nil
}
// loadModel loads the TensorFlow model.
func (t *Net) loadModel() error {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.ModelLoaded() {
return nil
}
modelPath := path.Join(t.modelPath)
log.Infof("faces: loading %s", clean.Log(filepath.Base(modelPath)))
// Load model
model, err := tf.LoadSavedModel(modelPath, t.modelTags, nil)
if err != nil {
return err
}
t.model = model
return nil
}
// getEmbeddings returns the face embeddings for an image.
func (t *Net) getEmbeddings(img image.Image) Embeddings {
tensor, err := imageToTensor(img, CropSize.Width, CropSize.Height)
if err != nil {
log.Errorf("faces: failed to convert image to tensor: %s", err)
}
// TODO: pre-whiten image as in facenet
trainPhaseBoolTensor, err := tf.NewTensor(false)
output, err := t.model.Session.Run(
map[tf.Output]*tf.Tensor{
t.model.Graph.Operation("input").Output(0): tensor,
t.model.Graph.Operation("phase_train").Output(0): trainPhaseBoolTensor,
},
[]tf.Output{
t.model.Graph.Operation("embeddings").Output(0),
},
nil)
if err != nil {
log.Errorf("faces: %s", err)
}
if len(output) < 1 {
log.Errorf("faces: inference failed, no output")
} else {
return NewEmbeddings(output[0].Value().([][]float32))
}
return nil
}
func imageToTensor(img image.Image, imageHeight, imageWidth int) (tfTensor *tf.Tensor, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("faces: %s (panic)\nstack: %s", r, debug.Stack())
}
}()
if imageHeight <= 0 || imageWidth <= 0 {
return tfTensor, fmt.Errorf("faces: image width and height must be > 0")
}
var tfImage [1][][][3]float32
for j := 0; j < imageHeight; j++ {
tfImage[0] = append(tfImage[0], make([][3]float32, imageWidth))
}
for i := 0; i < imageWidth; i++ {
for j := 0; j < imageHeight; j++ {
r, g, b, _ := img.At(i, j).RGBA()
tfImage[0][j][i][0] = convertValue(r)
tfImage[0][j][i][1] = convertValue(g)
tfImage[0][j][i][2] = convertValue(b)
}
}
return tf.NewTensor(tfImage)
}
func convertValue(value uint32) float32 {
return (float32(value>>8) - float32(127.5)) / float32(127.5)
}

View File

@@ -1,201 +0,0 @@
package nsfw
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sync"
tf "github.com/tensorflow/tensorflow/tensorflow/go"
"github.com/tensorflow/tensorflow/tensorflow/go/op"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media/http/header"
)
// Detector uses TensorFlow to label drawing, hentai, neutral, porn and sexy images.
type Detector struct {
model *tf.SavedModel
modelPath string
modelTags []string
labels []string
mutex sync.Mutex
}
// New returns a new detector instance.
func New(modelPath string) *Detector {
return &Detector{modelPath: modelPath, modelTags: []string{"serve"}}
}
// File returns matching labels for a jpeg media file.
func (t *Detector) File(filename string) (result Labels, err error) {
if fs.MimeType(filename) != header.ContentTypeJpeg {
return result, fmt.Errorf("nsfw: %s is not a jpeg file", clean.Log(filepath.Base(filename)))
}
imageBuffer, err := os.ReadFile(filename)
if err != nil {
return result, err
}
return t.Labels(imageBuffer)
}
// Labels returns matching labels for a jpeg media string.
func (t *Detector) Labels(img []byte) (result Labels, err error) {
if err := t.loadModel(); err != nil {
return result, err
}
// Make tensor
tensor, err := createTensorFromImage(img, "jpeg")
if err != nil {
return result, fmt.Errorf("nsfw: %s", err)
}
// Run inference
output, err := t.model.Session.Run(
map[tf.Output]*tf.Tensor{
t.model.Graph.Operation("input_tensor").Output(0): tensor,
},
[]tf.Output{
t.model.Graph.Operation("nsfw_cls_model/final_prediction").Output(0),
},
nil)
if err != nil {
return result, fmt.Errorf("nsfw: %s (run inference)", err.Error())
}
if len(output) < 1 {
return result, fmt.Errorf("nsfw: inference failed, no output")
}
// Return best labels
result = t.getLabels(output[0].Value().([][]float32)[0])
log.Tracef("nsfw: image classified as %+v", result)
return result, nil
}
func (t *Detector) loadLabels(path string) error {
modelLabels := path + "/labels.txt"
log.Infof("nsfw: loading labels from labels.txt")
// Load labels
f, err := os.Open(modelLabels)
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
// Labels are separated by newlines
for scanner.Scan() {
t.labels = append(t.labels, scanner.Text())
}
if err := scanner.Err(); err != nil {
return err
}
return nil
}
func (t *Detector) loadModel() error {
t.mutex.Lock()
defer t.mutex.Unlock()
if t.model != nil {
// Already loaded
return nil
}
log.Infof("nsfw: loading %s", clean.Log(filepath.Base(t.modelPath)))
// Load model
model, err := tf.LoadSavedModel(t.modelPath, t.modelTags, nil)
if err != nil {
return err
}
t.model = model
return t.loadLabels(t.modelPath)
}
func (t *Detector) getLabels(p []float32) Labels {
return Labels{
Drawing: p[0],
Hentai: p[1],
Neutral: p[2],
Porn: p[3],
Sexy: p[4],
}
}
func transformImageGraph(imageFormat string) (graph *tf.Graph, input, output tf.Output, err error) {
const (
H, W = 224, 224
Mean = float32(117)
Scale = float32(1)
)
s := op.NewScope()
input = op.Placeholder(s, tf.String)
// Decode PNG or JPEG
var decode tf.Output
if imageFormat == "png" {
decode = op.DecodePng(s, input, op.DecodePngChannels(3))
} else {
decode = op.DecodeJpeg(s, input, op.DecodeJpegChannels(3))
}
// Div and Sub perform (value-Mean)/Scale for each pixel
output = op.Div(s,
op.Sub(s,
// Resize to 224x224 with bilinear interpolation
op.ResizeBilinear(s,
// Create a batch containing a single image
op.ExpandDims(s,
// Use decoded pixel values
op.Cast(s, decode, tf.Float),
op.Const(s.SubScope("make_batch"), int32(0))),
op.Const(s.SubScope("size"), []int32{H, W})),
op.Const(s.SubScope("mean"), Mean)),
op.Const(s.SubScope("scale"), Scale))
graph, err = s.Finalize()
return graph, input, output, err
}
func createTensorFromImage(image []byte, imageFormat string) (*tf.Tensor, error) {
tensor, err := tf.NewTensor(string(image))
if err != nil {
return nil, err
}
graph, input, output, err := transformImageGraph(imageFormat)
if err != nil {
return nil, err
}
session, err := tf.NewSession(graph, nil)
if err != nil {
return nil, err
}
defer session.Close()
normalized, err := session.Run(
map[tf.Output]*tf.Tensor{input: tensor},
[]tf.Output{output},
nil)
if err != nil {
return nil, err
}
return normalized[0], nil
}

157
internal/ai/nsfw/model.go Normal file
View File

@@ -0,0 +1,157 @@
package nsfw
import (
"fmt"
"os"
"path/filepath"
"sync"
tf "github.com/wamuir/graft/tensorflow"
"github.com/photoprism/photoprism/internal/ai/tensorflow"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/http/header"
"github.com/photoprism/photoprism/pkg/media/http/scheme"
)
// Model uses TensorFlow to label drawing, hentai, neutral, porn and sexy images.
type Model struct {
model *tf.SavedModel
modelPath string
resolution int
modelTags []string
labels []string
disabled bool
mutex sync.Mutex
}
// NewModel returns a new detector instance.
func NewModel(modelPath string, resolution int, tags []string, disabled bool) *Model {
if resolution <= 0 {
resolution = 224
}
if len(tags) == 0 {
tags = []string{"serve"}
}
return &Model{modelPath: modelPath, resolution: resolution, modelTags: tags, disabled: disabled}
}
// File checks the specified JPEG file for inappropriate content.
func (m *Model) File(fileName string) (result Result, err error) {
if fs.MimeType(fileName) != header.ContentTypeJpeg {
return result, fmt.Errorf("nsfw: %s is not a jpeg file", clean.Log(filepath.Base(fileName)))
}
var img []byte
if img, err = os.ReadFile(fileName); err != nil {
return result, err
}
return m.Run(img)
}
// Url checks the JPEG file from the specified https or data URL for inappropriate content.
func (m *Model) Url(imgUrl string) (result Result, err error) {
if m.disabled {
return result, nil
}
var img []byte
if img, err = media.ReadUrl(imgUrl, scheme.HttpsData); err != nil {
return result, err
}
return m.Run(img)
}
// Run returns matching labels for a jpeg media string.
func (m *Model) Run(img []byte) (result Result, err error) {
if loadErr := m.loadModel(); loadErr != nil {
return result, loadErr
}
// Create input tensor from image.
input, err := tensorflow.ImageTransform(img, fs.ImageJpeg, m.resolution)
if err != nil {
return result, fmt.Errorf("nsfw: %s", err)
}
// Run inference.
output, err := m.model.Session.Run(
map[tf.Output]*tf.Tensor{
m.model.Graph.Operation("input_tensor").Output(0): input,
},
[]tf.Output{
m.model.Graph.Operation("nsfw_cls_model/final_prediction").Output(0),
},
nil)
if err != nil {
return result, fmt.Errorf("nsfw: %s (run inference)", err.Error())
}
if len(output) < 1 {
return result, fmt.Errorf("nsfw: inference failed, no output")
}
// Return best labels.
result = m.getLabels(output[0].Value().([][]float32)[0])
log.Tracef("nsfw: image classified as %+v", result)
return result, nil
}
// Init initialises tensorflow models if not disabled
func (m *Model) Init() (err error) {
if m.disabled {
return nil
}
return m.loadModel()
}
func (m *Model) loadModel() error {
// Use mutex to prevent the model from being loaded and
// initialized twice by different indexing workers.
m.mutex.Lock()
defer m.mutex.Unlock()
if m.model != nil {
// Already loaded
return nil
}
log.Infof("nsfw: loading %s", clean.Log(filepath.Base(m.modelPath)))
// Load saved TensorFlow model from the specified path.
model, err := tensorflow.SavedModel(m.modelPath, m.modelTags)
if err != nil {
return err
}
m.model = model
return m.loadLabels(m.modelPath)
}
func (m *Model) loadLabels(modelPath string) (err error) {
m.labels, err = tensorflow.LoadLabels(modelPath)
return nil
}
func (m *Model) getLabels(p []float32) Result {
return Result{
Drawing: p[0],
Hentai: p[1],
Neutral: p[2],
Porn: p[3],
Sexy: p[4],
}
}

View File

@@ -36,7 +36,7 @@ const (
var log = event.Log
type Labels struct {
type Result struct {
Drawing float32
Hentai float32
Neutral float32
@@ -45,12 +45,12 @@ type Labels struct {
}
// IsSafe returns true if the image is probably safe for work.
func (l *Labels) IsSafe() bool {
return !l.NSFW(ThresholdSafe)
func (l *Result) IsSafe() bool {
return !l.IsNsfw(ThresholdSafe)
}
// NSFW returns true if the image is may not be safe for work.
func (l *Labels) NSFW(threshold float32) bool {
// IsNsfw returns true if the image is may not be safe for work.
func (l *Result) IsNsfw(threshold float32) bool {
if l.Neutral > 0.25 {
return false
}

View File

@@ -13,10 +13,10 @@ import (
var modelPath, _ = filepath.Abs("../../../assets/nsfw")
var detector = New(modelPath)
var detector = NewModel(modelPath, 224, nil, false)
func TestIsSafe(t *testing.T) {
detect := func(filename string) Labels {
detect := func(filename string) Result {
result, err := detector.File(filename)
if err != nil {
@@ -24,12 +24,12 @@ func TestIsSafe(t *testing.T) {
}
assert.NotNil(t, result)
assert.IsType(t, Labels{}, result)
assert.IsType(t, Result{}, result)
return result
}
expected := map[string]Labels{
expected := map[string]Result{
"beach_sand.jpg": {0, 0, 0.9, 0, 0},
"beach_wood.jpg": {0, 0, 0.36, 0.59, 0},
"cat_brown.jpg": {0, 0, 0.93, 0, 0},
@@ -97,28 +97,28 @@ func TestIsSafe(t *testing.T) {
}
}
func TestNSFW(t *testing.T) {
porn := Labels{0, 0, 0.11, 0.88, 0}
sexy := Labels{0, 0, 0.2, 0.59, 0.98}
max := Labels{0, 0.999, 0.1, 0.999, 0.999}
drawing := Labels{0.999, 0, 0, 0, 0}
hentai := Labels{0, 0.80, 0.2, 0, 0}
func TestIsNsfw(t *testing.T) {
porn := Result{0, 0, 0.11, 0.88, 0}
sexy := Result{0, 0, 0.2, 0.59, 0.98}
maxi := Result{0, 0.999, 0.1, 0.999, 0.999}
drawing := Result{0.999, 0, 0, 0, 0}
hentai := Result{0, 0.80, 0.2, 0, 0}
assert.Equal(t, true, porn.NSFW(ThresholdSafe))
assert.Equal(t, true, sexy.NSFW(ThresholdSafe))
assert.Equal(t, true, hentai.NSFW(ThresholdSafe))
assert.Equal(t, false, drawing.NSFW(ThresholdSafe))
assert.Equal(t, true, max.NSFW(ThresholdSafe))
assert.Equal(t, true, porn.IsNsfw(ThresholdSafe))
assert.Equal(t, true, sexy.IsNsfw(ThresholdSafe))
assert.Equal(t, true, hentai.IsNsfw(ThresholdSafe))
assert.Equal(t, false, drawing.IsNsfw(ThresholdSafe))
assert.Equal(t, true, maxi.IsNsfw(ThresholdSafe))
assert.Equal(t, true, porn.NSFW(ThresholdMedium))
assert.Equal(t, true, sexy.NSFW(ThresholdMedium))
assert.Equal(t, false, hentai.NSFW(ThresholdMedium))
assert.Equal(t, false, drawing.NSFW(ThresholdMedium))
assert.Equal(t, true, max.NSFW(ThresholdMedium))
assert.Equal(t, true, porn.IsNsfw(ThresholdMedium))
assert.Equal(t, true, sexy.IsNsfw(ThresholdMedium))
assert.Equal(t, false, hentai.IsNsfw(ThresholdMedium))
assert.Equal(t, false, drawing.IsNsfw(ThresholdMedium))
assert.Equal(t, true, maxi.IsNsfw(ThresholdMedium))
assert.Equal(t, false, porn.NSFW(ThresholdHigh))
assert.Equal(t, false, sexy.NSFW(ThresholdHigh))
assert.Equal(t, false, hentai.NSFW(ThresholdHigh))
assert.Equal(t, false, drawing.NSFW(ThresholdHigh))
assert.Equal(t, true, max.NSFW(ThresholdHigh))
assert.Equal(t, false, porn.IsNsfw(ThresholdHigh))
assert.Equal(t, false, sexy.IsNsfw(ThresholdHigh))
assert.Equal(t, false, hentai.IsNsfw(ThresholdHigh))
assert.Equal(t, false, drawing.IsNsfw(ThresholdHigh))
assert.Equal(t, true, maxi.IsNsfw(ThresholdHigh))
}

View File

@@ -0,0 +1,145 @@
package tensorflow
import (
"bytes"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"os"
"runtime/debug"
tf "github.com/wamuir/graft/tensorflow"
"github.com/wamuir/graft/tensorflow/op"
"github.com/photoprism/photoprism/pkg/fs"
)
const (
Mean = float32(117)
Scale = float32(1)
)
func ImageFromFile(fileName string, resolution int) (*tf.Tensor, error) {
if img, err := OpenImage(fileName); err != nil {
return nil, err
} else {
return Image(img, resolution)
}
}
func OpenImage(fileName string) (image.Image, error) {
f, err := os.Open(fileName)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
return img, err
}
func ImageFromBytes(b []byte, resolution int) (*tf.Tensor, error) {
img, _, imgErr := image.Decode(bytes.NewReader(b))
if imgErr != nil {
return nil, imgErr
}
return Image(img, resolution)
}
func Image(img image.Image, resolution int) (tfTensor *tf.Tensor, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("tensorflow: %s (panic)\nstack: %s", r, debug.Stack())
}
}()
if resolution <= 0 {
return tfTensor, fmt.Errorf("tensorflow: resolution must be larger 0")
}
var tfImage [1][][][3]float32
for j := 0; j < resolution; j++ {
tfImage[0] = append(tfImage[0], make([][3]float32, resolution))
}
for i := 0; i < resolution; i++ {
for j := 0; j < resolution; j++ {
r, g, b, _ := img.At(i, j).RGBA()
tfImage[0][j][i][0] = convertValue(r, 127.5)
tfImage[0][j][i][1] = convertValue(g, 127.5)
tfImage[0][j][i][2] = convertValue(b, 127.5)
}
}
return tf.NewTensor(tfImage)
}
// ImageTransform transforms the given image into a *tf.Tensor and returns it.
func ImageTransform(image []byte, imageFormat fs.Type, resolution int) (*tf.Tensor, error) {
tensor, err := tf.NewTensor(string(image))
if err != nil {
return nil, err
}
graph, input, output, err := transformImageGraph(imageFormat, resolution)
if err != nil {
return nil, err
}
session, err := tf.NewSession(graph, nil)
if err != nil {
return nil, err
}
defer session.Close()
normalized, err := session.Run(
map[tf.Output]*tf.Tensor{input: tensor},
[]tf.Output{output},
nil)
if err != nil {
return nil, err
}
return normalized[0], nil
}
func transformImageGraph(imageFormat fs.Type, resolution int) (graph *tf.Graph, input, output tf.Output, err error) {
s := op.NewScope()
input = op.Placeholder(s, tf.String)
// Assume the image is a JPEG, or a PNG if explicitly specified.
var decodedImage tf.Output
switch imageFormat {
case fs.ImagePng:
decodedImage = op.DecodePng(s, input, op.DecodePngChannels(3))
default:
decodedImage = op.DecodeJpeg(s, input, op.DecodeJpegChannels(3))
}
output = op.Div(s,
op.Sub(s,
op.ResizeBilinear(s,
op.ExpandDims(s,
op.Cast(s, decodedImage, tf.Float),
op.Const(s.SubScope("make_batch"), int32(0))),
op.Const(s.SubScope("size"), []int32{int32(resolution), int32(resolution)})),
op.Const(s.SubScope("mean"), Mean)),
op.Const(s.SubScope("scale"), Scale))
graph, err = s.Finalize()
return graph, input, output, err
}
func convertValue(value uint32, mean float32) float32 {
if mean == 0 {
mean = 127.5
}
return (float32(value>>8) - mean) / mean
}

View File

@@ -0,0 +1,42 @@
package tensorflow
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/wamuir/graft/tensorflow"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestConvertValue(t *testing.T) {
result := convertValue(uint32(98765432), 127.5)
assert.Equal(t, float32(3024.898), result)
}
func TestImageFromBytes(t *testing.T) {
var assetsPath = fs.Abs("../../../assets")
var examplesPath = assetsPath + "/examples"
t.Run("CatJpeg", func(t *testing.T) {
imageBuffer, err := os.ReadFile(examplesPath + "/cat_brown.jpg")
if err != nil {
t.Fatal(err)
}
result, err := ImageFromBytes(imageBuffer, 224)
assert.Equal(t, tensorflow.DataType(0x1), result.DataType())
assert.Equal(t, int64(1), result.Shape()[0])
assert.Equal(t, int64(224), result.Shape()[2])
})
t.Run("Document", func(t *testing.T) {
imageBuffer, err := os.ReadFile(examplesPath + "/Random.docx")
assert.Nil(t, err)
result, err := ImageFromBytes(imageBuffer, 224)
assert.Empty(t, result)
assert.EqualError(t, err, "image: unknown format")
})
}

View File

@@ -0,0 +1,32 @@
package tensorflow
import (
"bufio"
"os"
)
// LoadLabels loads the labels of classification models from the specified path and returns them.
func LoadLabels(modelPath string) (labels []string, err error) {
modelLabels := modelPath + "/labels.txt"
log.Infof("tensorflow: loading model labels from labels.txt")
f, err := os.Open(modelLabels)
if err != nil {
return labels, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
// Labels are separated by newlines
for scanner.Scan() {
labels = append(labels, scanner.Text())
}
err = scanner.Err()
return labels, err
}

View File

@@ -0,0 +1,20 @@
package tensorflow
import (
"path/filepath"
tf "github.com/wamuir/graft/tensorflow"
"github.com/photoprism/photoprism/pkg/clean"
)
// SavedModel loads a saved TensorFlow model from the specified path.
func SavedModel(modelPath string, tags []string) (model *tf.SavedModel, err error) {
log.Infof("tensorflow: loading %s", clean.Log(filepath.Base(modelPath)))
if len(tags) == 0 {
tags = []string{"serve"}
}
return tf.LoadSavedModel(modelPath, tags, nil)
}

View File

@@ -0,0 +1,31 @@
/*
Package tensorflow provides TensorFlow utility functions.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package tensorflow
import (
"github.com/photoprism/photoprism/internal/event"
)
var log = event.Log

View File

@@ -0,0 +1,66 @@
package vision
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/media/http/header"
)
// PerformApiRequest performs a Vision API request and returns the result.
func PerformApiRequest(apiRequest *ApiRequest, uri, method, key string) (apiResponse *ApiResponse, err error) {
if apiRequest == nil {
return apiResponse, errors.New("api request is nil")
}
data, jsonErr := apiRequest.JSON()
if jsonErr != nil {
return apiResponse, jsonErr
}
// Create HTTP client and authenticated service API request.
client := http.Client{Timeout: ServiceTimeout}
req, reqErr := http.NewRequest(method, uri, bytes.NewReader(data))
// Add "application/json" content type header.
header.SetContentType(req, header.ContentTypeJson)
// Add an authentication header if an access token is configured.
if key != "" {
header.SetAuthorization(req, key)
}
if reqErr != nil {
return apiResponse, reqErr
}
// Perform API request.
clientResp, clientErr := client.Do(req)
if clientErr != nil {
return apiResponse, clientErr
}
// Parse and return response, or an error if the request failed.
switch apiRequest.GetResponseFormat() {
case ApiFormatVision:
apiResponse = &ApiResponse{}
if apiJson, apiErr := io.ReadAll(clientResp.Body); apiErr != nil {
return apiResponse, apiErr
} else if apiErr = json.Unmarshal(apiJson, apiResponse); apiErr != nil {
return apiResponse, apiErr
} else if clientResp.StatusCode >= 300 {
log.Debugf("vision: %s (status code %d)", apiJson, clientResp.StatusCode)
}
default:
return apiResponse, fmt.Errorf("unsupported response format %s", clean.Log(apiRequest.responseFormat))
}
return apiResponse, nil
}

View File

@@ -0,0 +1,45 @@
package vision
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media/http/scheme"
)
func TestNewApiRequest(t *testing.T) {
var assetsPath = fs.Abs("../../../assets")
var examplesPath = assetsPath + "/examples"
t.Run("Data", func(t *testing.T) {
thumbnails := Files{examplesPath + "/chameleon_lime.jpg"}
result, err := NewApiRequestImages(thumbnails, scheme.Data)
assert.NoError(t, err)
assert.NotNil(t, result)
// t.Logf("request: %#v", result)
if result != nil {
json, jsonErr := result.JSON()
assert.NoError(t, jsonErr)
assert.NotEmpty(t, json)
// t.Logf("json: %s", json)
}
})
t.Run("Https", func(t *testing.T) {
thumbnails := Files{examplesPath + "/chameleon_lime.jpg"}
result, err := NewApiRequestImages(thumbnails, scheme.Https)
assert.NoError(t, err)
assert.NotNil(t, result)
// t.Logf("request: %#v", result)
if result != nil {
json, jsonErr := result.JSON()
assert.NoError(t, jsonErr)
assert.NotEmpty(t, json)
t.Logf("json: %s", json)
}
})
}

View File

@@ -0,0 +1,9 @@
package vision
type ApiFormat = string
const (
ApiFormatUrl ApiFormat = "url"
ApiFormatImages ApiFormat = "images"
ApiFormatVision ApiFormat = "vision"
)

View File

@@ -0,0 +1,145 @@
package vision
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"slices"
"strings"
"github.com/photoprism/photoprism/internal/api/download"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/http/scheme"
"github.com/photoprism/photoprism/pkg/rnd"
)
type Files = []string
// ApiRequest represents a Vision API service request.
type ApiRequest struct {
Id string `form:"id" yaml:"Id,omitempty" json:"id,omitempty"`
Model string `form:"model" yaml:"Model,omitempty" json:"model,omitempty"`
Url string `form:"url" yaml:"Url,omitempty" json:"url,omitempty"`
Images Files `form:"images" yaml:"Images,omitempty" json:"images,omitempty"`
responseFormat ApiFormat `form:"-"`
}
// NewApiRequest returns a new service API request with the specified format and payload.
func NewApiRequest(requestFormat ApiFormat, files Files, fileScheme scheme.Type) (result *ApiRequest, err error) {
if len(files) == 0 {
return result, errors.New("missing files")
}
switch requestFormat {
case ApiFormatUrl:
return NewApiRequestUrl(files[0], fileScheme)
case ApiFormatImages, ApiFormatVision:
return NewApiRequestImages(files, fileScheme)
default:
return result, errors.New("invalid request format")
}
}
// NewApiRequestUrl returns a new Vision API request with the specified image Url as payload.
func NewApiRequestUrl(fileName string, fileScheme scheme.Type) (result *ApiRequest, err error) {
var imgUrl string
switch fileScheme {
case scheme.Https:
// Return if no thumbnail filenames were given.
if !fs.FileExistsNotEmpty(fileName) {
return result, errors.New("invalid image file name")
}
// Generate a random token for the remote service to download the file.
fileUuid := rnd.UUID()
if err = download.Register(fileUuid, fileName); err != nil {
return result, fmt.Errorf("%s (create download url)", err)
}
imgUrl = fmt.Sprintf("%s/%s", DownloadUrl, fileUuid)
case scheme.Data:
var u *url.URL
if u, err = url.Parse(fileName); err != nil {
return result, fmt.Errorf("%s (invalid image url)", err)
} else if !slices.Contains(scheme.HttpsHttp, u.Scheme) {
return nil, fmt.Errorf("unsupported image url scheme %s", clean.Log(u.Scheme))
} else {
imgUrl = u.String()
}
default:
return nil, fmt.Errorf("unsupported file scheme %s", clean.Log(fileScheme))
}
return &ApiRequest{
Id: rnd.UUID(),
Model: "",
Url: imgUrl,
responseFormat: ApiFormatVision,
}, nil
}
// NewApiRequestImages returns a new Vision API request with the specified images as payload.
func NewApiRequestImages(images Files, fileScheme scheme.Type) (*ApiRequest, error) {
imageUrls := make(Files, len(images))
if fileScheme == scheme.Https && !strings.HasPrefix(DownloadUrl, "https://") {
log.Tracef("vision: file request scheme changed from https to data because https is not configured")
fileScheme = scheme.Data
}
for i := range images {
switch fileScheme {
case scheme.Https:
fileUuid := rnd.UUID()
if err := download.Register(fileUuid, images[i]); err != nil {
return nil, fmt.Errorf("%s (create download url)", err)
} else {
imageUrls[i] = fmt.Sprintf("%s/%s", DownloadUrl, fileUuid)
}
case scheme.Data:
if file, err := os.Open(images[i]); err != nil {
return nil, fmt.Errorf("%s (create data url)", err)
} else {
imageUrls[i] = media.DataUrl(file)
}
default:
return nil, fmt.Errorf("unsupported file scheme %s", clean.Log(fileScheme))
}
}
return &ApiRequest{
Id: rnd.UUID(),
Model: "",
Images: imageUrls,
responseFormat: ApiFormatVision,
}, nil
}
// GetId returns the request ID string and generates a random ID if none was set.
func (r *ApiRequest) GetId() string {
if r.Id == "" {
r.Id = rnd.UUID()
}
return r.Id
}
// GetResponseFormat returns the expected response format type.
func (r *ApiRequest) GetResponseFormat() ApiFormat {
if r.responseFormat == "" {
return ApiFormatVision
}
return r.responseFormat
}
// JSON returns the request data as JSON-encoded bytes.
func (r *ApiRequest) JSON() ([]byte, error) {
return json.Marshal(*r)
}

View File

@@ -0,0 +1,131 @@
package vision
import (
"errors"
"fmt"
"math"
"net/http"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/pkg/clean"
)
// ApiResponse represents a Vision API service response.
type ApiResponse struct {
Id string `yaml:"Id,omitempty" json:"id,omitempty"`
Code int `yaml:"Code,omitempty" json:"code,omitempty"`
Error string `yaml:"Error,omitempty" json:"error,omitempty"`
Model *Model `yaml:"Model,omitempty" json:"model,omitempty"`
Result ApiResult `yaml:"Result,omitempty" json:"result,omitempty"`
}
// Err returns an error if the request has failed.
func (r *ApiResponse) Err() error {
if r == nil {
return errors.New("response is nil")
}
if r.Code >= 400 {
if r.Error != "" {
return errors.New(r.Error)
}
return fmt.Errorf("error %d", r.Code)
} else if r.Result.IsEmpty() {
return errors.New("no result")
}
return nil
}
// HasResult checks if there is at least one result in the response data.
func (r *ApiResponse) HasResult() bool {
if r == nil {
return false
}
return !r.Result.IsEmpty()
}
// ApiResult represents the model response(s) to a Vision API service
// request and can optionally include data from multiple models.
type ApiResult struct {
Labels []LabelResult `yaml:"Labels,omitempty" json:"labels,omitempty"`
Nsfw []nsfw.Result `yaml:"Nsfw,omitempty" json:"nsfw,omitempty"`
Embeddings []face.Embeddings `yaml:"Embeddings,omitempty" json:"embeddings,omitempty"`
Caption *CaptionResult `yaml:"Caption,omitempty" json:"caption,omitempty"`
}
// IsEmpty checks if there is no result in the response data.
func (r *ApiResult) IsEmpty() bool {
if r == nil {
return false
}
return len(r.Labels) == 0 && len(r.Nsfw) == 0 && len(r.Embeddings) == 0 && r.Caption == nil
}
// CaptionResult represents the result generated by a caption generation model.
type CaptionResult struct {
Text string `yaml:"Text,omitempty" json:"text,omitempty"`
Source string `yaml:"Source,omitempty" json:"source,omitempty"`
Confidence float32 `yaml:"Confidence,omitempty" json:"confidence,omitempty"`
}
// LabelResult represents a label generated by an image classification model.
type LabelResult struct {
Name string `yaml:"Name,omitempty" json:"name"`
Source string `yaml:"Source,omitempty" json:"source"`
Priority int `yaml:"Priority,omitempty" json:"priority,omitempty"`
Confidence float32 `yaml:"Confidence,omitempty" json:"confidence,omitempty"`
Topicality float32 `yaml:"Topicality,omitempty" json:"topicality,omitempty"`
Categories []string `yaml:"Categories,omitempty" json:"categories,omitempty"`
}
// ToClassify returns the label results as classify.Label.
func (r LabelResult) ToClassify() classify.Label {
uncertainty := math.RoundToEven(float64(100 - r.Confidence*100))
return classify.Label{
Name: r.Name,
Source: r.Source,
Priority: r.Priority,
Uncertainty: int(uncertainty),
Categories: r.Categories}
}
// NewApiError generates a Vision API error response based on the specified HTTP status code.
func NewApiError(id string, code int) ApiResponse {
return ApiResponse{
Id: clean.Type(id),
Code: code,
Error: http.StatusText(code),
}
}
// NewLabelsResponse generates a new Vision API image classification service response.
func NewLabelsResponse(id string, model *Model, results classify.Labels) ApiResponse {
if model == nil {
model = NasnetModel
}
var labels = make([]LabelResult, 0, len(results))
for _, label := range results {
labels = append(labels, LabelResult{
Name: label.Name,
Source: label.Source,
Priority: label.Priority,
Confidence: label.Confidence(),
Categories: label.Categories})
}
return ApiResponse{
Id: clean.Type(id),
Code: http.StatusOK,
Model: &Model{Type: ModelTypeLabels, Name: model.Name, Version: model.Version, Resolution: model.Resolution},
Result: ApiResult{Labels: labels},
}
}

View File

@@ -0,0 +1,53 @@
package vision
import (
"errors"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/media"
)
// Caption returns generated captions for the specified images.
func Caption(imgName string, src media.Src) (result CaptionResult, err error) {
// Return if there is no configuration or no image classification models are configured.
if Config == nil {
return result, errors.New("vision service is not configured")
} else if model := Config.Model(ModelTypeCaption); model != nil {
// Use remote service API if a server endpoint has been configured.
if uri, method := model.Endpoint(); uri != "" && method != "" {
var apiRequest *ApiRequest
var apiResponse *ApiResponse
if apiRequest, err = NewApiRequest(model.EndpointRequestFormat(), Files{imgName}, model.EndpointFileScheme()); err != nil {
return result, err
}
if model.Name != "" {
apiRequest.Model = model.Name
}
/* if json, _ := apiRequest.JSON(); len(json) > 0 {
log.Debugf("request: %s", json)
} */
if apiResponse, err = PerformApiRequest(apiRequest, uri, method, model.EndpointKey()); err != nil {
return result, err
} else if apiResponse.Result.Caption == nil {
return result, errors.New("invalid caption model response")
}
// Set image as the default caption source.
if apiResponse.Result.Caption.Text != "" && apiResponse.Result.Caption.Source == "" {
apiResponse.Result.Caption.Source = entity.SrcImage
}
result = *apiResponse.Result.Caption
} else {
return result, errors.New("invalid caption model configuration")
}
} else {
return result, errors.New("missing caption model")
}
return result, nil
}

View File

@@ -0,0 +1,41 @@
package vision
import (
"net"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/media"
)
func TestCaption(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
} else if _, err := net.DialTimeout("tcp", "photoprism-vision:5000", 10*time.Second); err != nil {
t.Skip("skipping test because photoprism-vision is not running.")
}
t.Run("Success", func(t *testing.T) {
expectedText := "An image of sound waves"
result, err := Caption("https://dl.photoprism.app/img/artwork/colorwaves-400.jpg", media.SrcRemote)
assert.NoError(t, err)
assert.IsType(t, CaptionResult{}, result)
assert.LessOrEqual(t, float32(0.0), result.Confidence)
t.Logf("caption: %#v", result)
assert.Equal(t, expectedText, result.Text)
})
t.Run("Invalid", func(t *testing.T) {
result, err := Caption("", media.SrcLocal)
assert.Error(t, err)
assert.IsType(t, CaptionResult{}, result)
assert.Equal(t, "", result.Text)
assert.Equal(t, float32(0.0), result.Confidence)
})
}

View File

@@ -0,0 +1,25 @@
package vision
import (
"net/http"
"time"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media/http/scheme"
)
var (
AssetsPath = fs.Abs("../../../assets")
FaceNetModelPath = fs.Abs("../../../assets/facenet")
NsfwModelPath = fs.Abs("../../../assets/nsfw")
CachePath = fs.Abs("../../../storage/cache")
DownloadUrl = ""
ServiceUri = ""
ServiceKey = ""
ServiceTimeout = time.Minute
ServiceMethod = http.MethodPost
ServiceFileScheme = scheme.Data
ServiceRequestFormat = ApiFormatVision
ServiceResponseFormat = ApiFormatVision
DefaultResolution = 224
)

View File

@@ -0,0 +1,37 @@
package vision
import (
"bytes"
"errors"
"fmt"
"image/jpeg"
"github.com/photoprism/photoprism/internal/ai/face"
)
// Face returns the embeddings for the specified face crop image.
func Face(imgData []byte) (embeddings face.Embeddings, err error) {
if len(imgData) == 0 {
return embeddings, errors.New("missing image")
}
if Config == nil {
return embeddings, errors.New("vision service is not configured")
} else if model := Config.Model(ModelTypeFace); model != nil {
img, imgErr := jpeg.Decode(bytes.NewReader(imgData))
if imgErr != nil {
return embeddings, imgErr
}
if tf := model.FaceModel(); tf == nil {
return embeddings, fmt.Errorf("invalid face model configuration")
} else if embeddings = tf.Run(img); !embeddings.Empty() {
return embeddings, nil
} else {
return face.Embeddings{}, nil
}
} else {
return embeddings, fmt.Errorf("no face model configured")
}
}

View File

@@ -0,0 +1,38 @@
package vision
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestFace(t *testing.T) {
t.Run("Success", func(t *testing.T) {
img, imgErr := os.ReadFile(fs.Abs("./testdata/face_160x160.jpg"))
if imgErr != nil {
t.Fatal(imgErr)
}
result, err := Face(img)
assert.NoError(t, err)
assert.IsType(t, face.Embeddings{}, result)
assert.Equal(t, 1, len(result))
// t.Log(result)
})
t.Run("InvalidFile", func(t *testing.T) {
result, err := Face([]byte{})
assert.Error(t, err)
assert.IsType(t, face.Embeddings{}, result)
assert.Equal(t, 0, len(result))
// t.Log(result)
})
}

View File

@@ -0,0 +1,89 @@
package vision
import (
"errors"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/thumb/crop"
)
// Faces detects faces in the specified image and generates embeddings from them.
func Faces(fileName string, minSize int, cacheCrop bool, expected int) (result face.Faces, err error) {
if fileName == "" {
return result, errors.New("missing image filename")
}
// Return if there is no configuration or no image classification models are configured.
if Config == nil {
return result, errors.New("vision service is not configured")
} else if model := Config.Model(ModelTypeFace); model != nil {
result, err = face.Detect(fileName, false, minSize)
if err != nil {
return result, err
}
// Skip embeddings?
if c := len(result); c == 0 || expected > 0 && c == expected {
return result, nil
}
if uri, method := model.Endpoint(); uri != "" && method != "" {
var faceCrops []string
var apiRequest *ApiRequest
var apiResponse *ApiResponse
faceCrops = make([]string, len(result))
for i, f := range result {
if f.Area.Col == 0 && f.Area.Row == 0 {
faceCrops[i] = ""
continue
}
if _, faceCrop, imgErr := crop.ImageFromThumb(fileName, f.CropArea(), face.CropSize, cacheCrop); imgErr != nil {
log.Errorf("vision: failed to create face crop (%s)", imgErr)
faceCrops[i] = ""
} else if faceCrop != "" {
faceCrops[i] = faceCrop
}
}
if apiRequest, err = NewApiRequest(model.EndpointRequestFormat(), faceCrops, model.EndpointFileScheme()); err != nil {
return result, err
}
if model.Name != "" {
apiRequest.Model = model.Name
}
if apiResponse, err = PerformApiRequest(apiRequest, uri, method, model.EndpointKey()); err != nil {
return result, err
}
for i := range result {
if len(apiResponse.Result.Embeddings) > i {
result[i].Embeddings = apiResponse.Result.Embeddings[i]
}
}
} else if tf := model.FaceModel(); tf != nil {
for i, f := range result {
if f.Area.Col == 0 && f.Area.Row == 0 {
continue
}
if img, _, imgErr := crop.ImageFromThumb(fileName, f.CropArea(), face.CropSize, cacheCrop); imgErr != nil {
log.Errorf("vision: failed to create face crop (%s)", imgErr)
} else if embeddings := tf.Run(img); !embeddings.Empty() {
result[i].Embeddings = embeddings
}
}
} else {
return result, errors.New("invalid face model configuration")
}
} else {
return result, errors.New("missing face model")
}
return result, nil
}

View File

@@ -0,0 +1,105 @@
package vision
import (
"errors"
"fmt"
"sort"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/media"
)
// Labels finds matching labels for the specified image.
func Labels(images Files, src media.Src) (result classify.Labels, err error) {
// Return if no thumbnail filenames were given.
if len(images) == 0 {
return result, errors.New("at least one image required")
}
// Return if there is no configuration or no image classification models are configured.
if Config == nil {
return result, errors.New("vision service is not configured")
} else if model := Config.Model(ModelTypeLabels); model != nil {
// Use remote service API if a server endpoint has been configured.
if uri, method := model.Endpoint(); uri != "" && method != "" {
var apiRequest *ApiRequest
var apiResponse *ApiResponse
if apiRequest, err = NewApiRequest(model.EndpointRequestFormat(), images, model.EndpointFileScheme()); err != nil {
return result, err
}
if model.Name != "" {
apiRequest.Model = model.Name
}
if apiResponse, err = PerformApiRequest(apiRequest, uri, method, model.EndpointKey()); err != nil {
return result, err
}
for _, label := range apiResponse.Result.Labels {
result = append(result, label.ToClassify())
}
} else if tf := model.ClassifyModel(); tf != nil {
// Predict labels with local TensorFlow model.
for i := range images {
var labels classify.Labels
switch src {
case media.SrcLocal:
labels, err = tf.File(images[i], Config.Thresholds.Confidence)
case media.SrcRemote:
labels, err = tf.Url(images[i], Config.Thresholds.Confidence)
default:
return result, fmt.Errorf("invalid image source %s", clean.Log(src))
}
if err != nil {
return result, err
}
result = mergeLabels(result, labels)
}
} else {
return result, errors.New("invalid labels model configuration")
}
} else {
return result, errors.New("missing labels model")
}
sort.Sort(result)
return result, nil
}
// mergeLabels combines existing labels with newly detected labels and returns the result.
func mergeLabels(result, labels classify.Labels) classify.Labels {
if len(labels) == 0 {
return result
}
for j := range labels {
found := false
for k := range result {
if labels[j].Name == result[k].Name {
found = true
if labels[j].Uncertainty < result[k].Uncertainty {
result[k].Uncertainty = labels[j].Uncertainty
}
if labels[j].Priority > result[k].Priority {
result[k].Priority = labels[j].Priority
}
}
}
if !found {
result = append(result, labels[j])
}
}
return result
}

View File

@@ -0,0 +1,59 @@
package vision
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
)
func TestLabels(t *testing.T) {
var assetsPath = fs.Abs("../../../assets")
var examplesPath = assetsPath + "/examples"
t.Run("Success", func(t *testing.T) {
result, err := Labels(Files{examplesPath + "/chameleon_lime.jpg"}, media.SrcLocal)
assert.NoError(t, err)
assert.IsType(t, classify.Labels{}, result)
assert.Equal(t, 1, len(result))
t.Log(result)
assert.Equal(t, "chameleon", result[0].Name)
assert.Equal(t, 7, result[0].Uncertainty)
})
t.Run("Cat224", func(t *testing.T) {
result, err := Labels(Files{examplesPath + "/cat_224.jpeg"}, media.SrcLocal)
assert.NoError(t, err)
assert.IsType(t, classify.Labels{}, result)
assert.Equal(t, 1, len(result))
t.Log(result)
assert.Equal(t, "cat", result[0].Name)
assert.InDelta(t, 59, result[0].Uncertainty, 10)
assert.InDelta(t, float32(0.41), result[0].Confidence(), 0.1)
})
t.Run("Cat720", func(t *testing.T) {
result, err := Labels(Files{examplesPath + "/cat_720.jpeg"}, media.SrcLocal)
assert.NoError(t, err)
assert.IsType(t, classify.Labels{}, result)
assert.Equal(t, 1, len(result))
t.Log(result)
assert.Equal(t, "cat", result[0].Name)
assert.InDelta(t, 60, result[0].Uncertainty, 10)
assert.InDelta(t, float32(0.4), result[0].Confidence(), 0.1)
})
t.Run("InvalidFile", func(t *testing.T) {
_, err := Labels(Files{examplesPath + "/notexisting.jpg"}, media.SrcLocal)
assert.Error(t, err)
})
}

250
internal/ai/vision/model.go Normal file
View File

@@ -0,0 +1,250 @@
package vision
import (
"fmt"
"path/filepath"
"sync"
"github.com/photoprism/photoprism/internal/ai/classify"
"github.com/photoprism/photoprism/internal/ai/face"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/media/http/scheme"
)
var modelMutex = sync.Mutex{}
// Model represents a computer vision model configuration.
type Model struct {
Type ModelType `yaml:"Type,omitempty" json:"type,omitempty"`
Name string `yaml:"Name,omitempty" json:"name,omitempty"`
Version string `yaml:"Version,omitempty" json:"version,omitempty"`
Resolution int `yaml:"Resolution,omitempty" json:"resolution,omitempty"`
Service Service `yaml:"Service,omitempty" json:"Service,omitempty"`
Path string `yaml:"Path,omitempty" json:"-"`
Tags []string `yaml:"Tags,omitempty" json:"-"`
Disabled bool `yaml:"Disabled,omitempty" json:"disabled,omitempty"`
classifyModel *classify.Model
faceModel *face.Model
nsfwModel *nsfw.Model
}
// Models represents a set of computer vision models.
type Models []*Model
// Endpoint returns the remote service request method and endpoint URL, if any.
func (m *Model) Endpoint() (uri, method string) {
if uri, method = m.Service.Endpoint(); uri != "" && method != "" {
return uri, method
} else if ServiceUri == "" {
return "", ""
} else if serviceType := clean.TypeLowerUnderscore(m.Type); serviceType == "" {
return "", ""
} else {
return fmt.Sprintf("%s/%s", ServiceUri, serviceType), ServiceMethod
}
}
// EndpointKey returns the access token belonging to the remote service endpoint, if any.
func (m *Model) EndpointKey() (key string) {
if key = m.Service.EndpointKey(); key != "" {
return key
} else {
return ServiceKey
}
}
// EndpointFileScheme returns the endpoint API request file scheme type.
func (m *Model) EndpointFileScheme() (fileScheme scheme.Type) {
if fileScheme = m.Service.EndpointFileScheme(); fileScheme != "" {
return fileScheme
}
return ServiceFileScheme
}
// EndpointRequestFormat returns the endpoint API request format.
func (m *Model) EndpointRequestFormat() (format ApiFormat) {
if format = m.Service.EndpointRequestFormat(); format != "" {
return format
}
return ServiceRequestFormat
}
// EndpointResponseFormat returns the endpoint API response format.
func (m *Model) EndpointResponseFormat() (format ApiFormat) {
if format = m.Service.EndpointResponseFormat(); format != "" {
return format
}
return ServiceResponseFormat
}
// ClassifyModel returns the matching classify model instance, if any.
func (m *Model) ClassifyModel() *classify.Model {
// Use mutex to prevent models from being loaded and
// initialized twice by different indexing workers.
modelMutex.Lock()
defer modelMutex.Unlock()
// Return the existing model instance if it has already been created.
if m.classifyModel != nil {
return m.classifyModel
}
switch m.Name {
case "":
log.Warnf("vision: missing name, model instance cannot be created")
return nil
case NasnetModel.Name, "nasnet":
// Load and initialize the Nasnet image classification model.
if model := classify.NewNasnet(AssetsPath, m.Disabled); model == nil {
return nil
} else if err := model.Init(); err != nil {
log.Errorf("vision: %s (init nasnet model)", err)
return nil
} else {
m.classifyModel = model
}
default:
// Set model path from model name if no path is configured.
if m.Path == "" {
m.Path = clean.TypeLowerUnderscore(m.Name)
}
// Set default thumbnail resolution if no tags are configured.
if m.Resolution <= 0 {
m.Resolution = DefaultResolution
}
// Set default tag if no tags are configured.
if len(m.Tags) == 0 {
m.Tags = []string{"serve"}
}
// Try to load custom model based on the configuration values.
if model := classify.NewModel(AssetsPath, m.Path, m.Resolution, m.Tags, m.Disabled); model == nil {
return nil
} else if err := model.Init(); err != nil {
log.Errorf("vision: %s (init %s)", err, m.Path)
return nil
} else {
m.classifyModel = model
}
}
return m.classifyModel
}
// FaceModel returns the matching face model instance, if any.
func (m *Model) FaceModel() *face.Model {
// Use mutex to prevent models from being loaded and
// initialized twice by different indexing workers.
modelMutex.Lock()
defer modelMutex.Unlock()
// Return the existing model instance if it has already been created.
if m.faceModel != nil {
return m.faceModel
}
switch m.Name {
case "":
log.Warnf("vision: missing name, model instance cannot be created")
return nil
case FacenetModel.Name, "facenet":
// Load and initialize the Nasnet image classification model.
if model := face.NewModel(FaceNetModelPath, CachePath, m.Resolution, m.Tags, m.Disabled); model == nil {
return nil
} else if err := model.Init(); err != nil {
log.Errorf("vision: %s (init %s)", err, m.Path)
return nil
} else {
m.faceModel = model
}
default:
// Set model path from model name if no path is configured.
if m.Path == "" {
m.Path = clean.TypeLowerUnderscore(m.Name)
}
// Set default thumbnail resolution if no tags are configured.
if m.Resolution <= 0 {
m.Resolution = DefaultResolution
}
// Set default tag if no tags are configured.
if len(m.Tags) == 0 {
m.Tags = []string{"serve"}
}
// Try to load custom model based on the configuration values.
if model := face.NewModel(filepath.Join(AssetsPath, m.Path), CachePath, m.Resolution, m.Tags, m.Disabled); model == nil {
return nil
} else if err := model.Init(); err != nil {
log.Errorf("vision: %s (init %s)", err, m.Path)
return nil
} else {
m.faceModel = model
}
}
return m.faceModel
}
// NsfwModel returns the matching nsfw model instance, if any.
func (m *Model) NsfwModel() *nsfw.Model {
// Use mutex to prevent models from being loaded and
// initialized twice by different indexing workers.
modelMutex.Lock()
defer modelMutex.Unlock()
// Return the existing model instance if it has already been created.
if m.nsfwModel != nil {
return m.nsfwModel
}
switch m.Name {
case "":
log.Warnf("vision: missing name, model instance cannot be created")
return nil
case NsfwModel.Name, "nsfw":
// Load and initialize the Nasnet image classification model.
if model := nsfw.NewModel(NsfwModelPath, m.Resolution, m.Tags, m.Disabled); model == nil {
return nil
} else if err := model.Init(); err != nil {
log.Errorf("vision: %s (init %s)", err, m.Path)
return nil
} else {
m.nsfwModel = model
}
default:
// Set model path from model name if no path is configured.
if m.Path == "" {
m.Path = clean.TypeLowerUnderscore(m.Name)
}
// Set default thumbnail resolution if no tags are configured.
if m.Resolution <= 0 {
m.Resolution = DefaultResolution
}
// Set default tag if no tags are configured.
if len(m.Tags) == 0 {
m.Tags = []string{"serve"}
}
// Try to load custom model based on the configuration values.
if model := nsfw.NewModel(filepath.Join(AssetsPath, m.Path), m.Resolution, m.Tags, m.Disabled); model == nil {
return nil
} else if err := model.Init(); err != nil {
log.Errorf("vision: %s (init %s)", err, m.Path)
return nil
} else {
m.nsfwModel = model
}
}
return m.nsfwModel
}

View File

@@ -0,0 +1,36 @@
package vision
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestModel(t *testing.T) {
t.Run("Nasnet", func(t *testing.T) {
ServiceUri = "https://app.localssl.dev/api/v1/vision"
uri, method := NasnetModel.Endpoint()
ServiceUri = ""
assert.Equal(t, "https://app.localssl.dev/api/v1/vision/labels", uri)
assert.Equal(t, http.MethodPost, method)
uri, method = NasnetModel.Endpoint()
assert.Equal(t, "", uri)
assert.Equal(t, "", method)
})
}
func TestParseTypes(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
result := ParseTypes("nsfw, labels, Caption")
assert.Equal(t, ModelTypes{"nsfw", "labels", "caption"}, result)
})
t.Run("None", func(t *testing.T) {
result := ParseTypes("")
assert.Equal(t, ModelTypes{}, result)
})
t.Run("Invalid", func(t *testing.T) {
result := ParseTypes("foo, captions")
assert.Equal(t, ModelTypes{}, result)
})
}

View File

@@ -0,0 +1,38 @@
package vision
import (
"slices"
"strings"
)
type ModelType = string
type ModelTypes = []ModelType
const (
ModelTypeLabels ModelType = "labels"
ModelTypeNsfw ModelType = "nsfw"
ModelTypeFace ModelType = "face"
ModelTypeCaption ModelType = "caption"
)
// ParseTypes parses a model type string.
func ParseTypes(s string) (types ModelTypes) {
if s = strings.TrimSpace(s); s == "" {
return ModelTypes{}
}
s = strings.ToLower(s)
types = make(ModelTypes, 0, strings.Count(s, ","))
for _, t := range strings.Split(s, ",") {
t = strings.TrimSpace(t)
switch t {
case ModelTypeLabels, ModelTypeNsfw, ModelTypeFace, ModelTypeCaption:
if !slices.Contains(types, t) {
types = append(types, t)
}
}
}
return types
}

View File

@@ -0,0 +1,42 @@
package vision
import (
"github.com/photoprism/photoprism/pkg/media/http/scheme"
)
// Default computer vision model configuration.
var (
NasnetModel = &Model{
Type: ModelTypeLabels,
Name: "NASNet",
Version: "Mobile",
Resolution: 224,
Tags: []string{"photoprism"},
}
NsfwModel = &Model{
Type: ModelTypeNsfw,
Name: "Nsfw",
Version: "",
Resolution: 224,
Tags: []string{"serve"},
}
FacenetModel = &Model{
Type: ModelTypeFace,
Name: "FaceNet",
Version: "",
Resolution: 160,
Tags: []string{"serve"},
}
CaptionModel = &Model{
Type: ModelTypeCaption,
Resolution: 224,
Service: Service{
Uri: "http://photoprism-vision:5000/api/v1/vision/caption",
FileScheme: scheme.Https,
RequestFormat: ApiFormatUrl,
ResponseFormat: ApiFormatVision,
},
}
DefaultModels = Models{NasnetModel, NsfwModel, FacenetModel, CaptionModel}
DefaultThresholds = Thresholds{Confidence: 10}
)

View File

@@ -0,0 +1,71 @@
package vision
import (
"errors"
"fmt"
"github.com/photoprism/photoprism/internal/ai/nsfw"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/media"
)
// Nsfw checks the specified images for inappropriate content.
func Nsfw(images Files, src media.Src) (result []nsfw.Result, err error) {
// Return if no thumbnail filenames were given.
if len(images) == 0 {
return result, errors.New("at least one image required")
}
result = make([]nsfw.Result, len(images))
// Return if there is no configuration or no image classification models are configured.
if Config == nil {
return result, errors.New("vision service is not configured")
} else if model := Config.Model(ModelTypeNsfw); model != nil {
// Use remote service API if a server endpoint has been configured.
if uri, method := model.Endpoint(); uri != "" && method != "" {
var apiRequest *ApiRequest
var apiResponse *ApiResponse
if apiRequest, err = NewApiRequest(model.EndpointRequestFormat(), images, model.EndpointFileScheme()); err != nil {
return result, err
}
if model.Name != "" {
apiRequest.Model = model.Name
}
if apiResponse, err = PerformApiRequest(apiRequest, uri, method, model.EndpointKey()); err != nil {
return result, err
}
result = apiResponse.Result.Nsfw
} else if tf := model.NsfwModel(); tf != nil {
// Predict labels with local TensorFlow model.
for i := range images {
var labels nsfw.Result
switch src {
case media.SrcLocal:
labels, err = tf.File(images[i])
case media.SrcRemote:
labels, err = tf.Url(images[i])
default:
return result, fmt.Errorf("invalid image source %s", clean.Log(src))
}
if err != nil {
log.Errorf("nsfw: %s", err)
}
result[i] = labels
}
} else {
return result, errors.New("invalid nsfw model configuration")
}
} else {
return result, errors.New("missing nsfw model")
}
return result, nil
}

Some files were not shown because too many files have changed in this diff Show More