mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 08:44:04 +01:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,6 +49,8 @@ frontend/coverage/
|
||||
/assets/nasnet
|
||||
/assets/nsfw
|
||||
/assets/static/build/
|
||||
/assets/*net
|
||||
/assets/vision
|
||||
/pro
|
||||
/plus
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
15
Makefile
15
Makefile
@@ -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
450
NOTICE
@@ -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.
|
||||
|
||||
|
||||
BIN
assets/examples/cat_224.jpeg
Normal file
BIN
assets/examples/cat_224.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/examples/cat_720.jpeg
Normal file
BIN
assets/examples/cat_720.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
assets/examples/example.zip
Normal file
BIN
assets/examples/example.zip
Normal file
Binary file not shown.
BIN
assets/examples/green.jpg
Normal file
BIN
assets/examples/green.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -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
|
||||
|
||||
35
compose.yaml
35
compose.yaml
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
136
frontend/package-lock.json
generated
136
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -194,11 +194,9 @@ export default {
|
||||
attach: document.body,
|
||||
},
|
||||
VOverlay: {
|
||||
scrim: true,
|
||||
transition: false,
|
||||
openDelay: 0,
|
||||
closeDelay: 0,
|
||||
attach: document.body,
|
||||
},
|
||||
VExpansionPanel: {
|
||||
tile: true,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="fade-transition">
|
||||
<div v-if="visible" id="p-notify" tabindex="-1">
|
||||
<v-snackbar
|
||||
:model-value="snackbar"
|
||||
<div
|
||||
:class="'p-notify--' + message.color"
|
||||
class="p-notify clickable"
|
||||
class="v-snackbar v-snackbar--bottom v-snackbar--center p-notify"
|
||||
role="alert"
|
||||
tabindex="-1"
|
||||
@click.stop.prevent="showNext"
|
||||
@update:model-value="onSnackbar"
|
||||
>
|
||||
<v-icon
|
||||
<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"
|
||||
:icon="'mdi-' + message.icon"
|
||||
:color="message.color"
|
||||
class="p-notify_icon"
|
||||
start
|
||||
></v-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 }}
|
||||
<template #actions>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
:color="'on-' + message.color"
|
||||
variant="text"
|
||||
class="p-notify__close"
|
||||
@click.stop.prevent="showNext"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -122,13 +122,17 @@ export default {
|
||||
onLoad() {
|
||||
this.loading = true;
|
||||
this.$nextTick(() => {
|
||||
if (document.activeElement !== this.$refs.input) {
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
onLoaded() {
|
||||
this.loading = false;
|
||||
this.$nextTick(() => {
|
||||
if (document.activeElement !== this.$refs.input) {
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
reset() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -396,6 +396,7 @@
|
||||
height: 48px;
|
||||
width: 44px;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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", "");
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
211
frontend/tests/acceptance/acceptance-public/shortcuts.js
Normal file
211
frontend/tests/acceptance/acceptance-public/shortcuts.js
Normal 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();
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
29
go.mod
@@ -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
60
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
223
internal/ai/classify/model.go
Normal file
223
internal/ai/classify/model.go
Normal 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)
|
||||
}
|
||||
@@ -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, 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.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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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("")
|
||||
|
||||
|
||||
@@ -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
190
internal/ai/face/model.go
Normal 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)
|
||||
}
|
||||
@@ -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" {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
157
internal/ai/nsfw/model.go
Normal 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],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
145
internal/ai/tensorflow/image.go
Normal file
145
internal/ai/tensorflow/image.go
Normal 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
|
||||
}
|
||||
42
internal/ai/tensorflow/image_test.go
Normal file
42
internal/ai/tensorflow/image_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
32
internal/ai/tensorflow/labels.go
Normal file
32
internal/ai/tensorflow/labels.go
Normal 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
|
||||
}
|
||||
20
internal/ai/tensorflow/model.go
Normal file
20
internal/ai/tensorflow/model.go
Normal 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)
|
||||
}
|
||||
31
internal/ai/tensorflow/tensorflow.go
Normal file
31
internal/ai/tensorflow/tensorflow.go
Normal 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
|
||||
66
internal/ai/vision/api_client.go
Normal file
66
internal/ai/vision/api_client.go
Normal 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
|
||||
}
|
||||
45
internal/ai/vision/api_client_test.go
Normal file
45
internal/ai/vision/api_client_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
9
internal/ai/vision/api_format.go
Normal file
9
internal/ai/vision/api_format.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package vision
|
||||
|
||||
type ApiFormat = string
|
||||
|
||||
const (
|
||||
ApiFormatUrl ApiFormat = "url"
|
||||
ApiFormatImages ApiFormat = "images"
|
||||
ApiFormatVision ApiFormat = "vision"
|
||||
)
|
||||
145
internal/ai/vision/api_request.go
Normal file
145
internal/ai/vision/api_request.go
Normal 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)
|
||||
}
|
||||
131
internal/ai/vision/api_response.go
Normal file
131
internal/ai/vision/api_response.go
Normal 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},
|
||||
}
|
||||
}
|
||||
53
internal/ai/vision/caption.go
Normal file
53
internal/ai/vision/caption.go
Normal 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
|
||||
}
|
||||
41
internal/ai/vision/caption_test.go
Normal file
41
internal/ai/vision/caption_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
25
internal/ai/vision/config.go
Normal file
25
internal/ai/vision/config.go
Normal 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
|
||||
)
|
||||
37
internal/ai/vision/face.go
Normal file
37
internal/ai/vision/face.go
Normal 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")
|
||||
}
|
||||
}
|
||||
38
internal/ai/vision/face_test.go
Normal file
38
internal/ai/vision/face_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
89
internal/ai/vision/faces.go
Normal file
89
internal/ai/vision/faces.go
Normal 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
|
||||
}
|
||||
105
internal/ai/vision/labels.go
Normal file
105
internal/ai/vision/labels.go
Normal 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
|
||||
}
|
||||
59
internal/ai/vision/labels_test.go
Normal file
59
internal/ai/vision/labels_test.go
Normal 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
250
internal/ai/vision/model.go
Normal 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
|
||||
}
|
||||
36
internal/ai/vision/model_test.go
Normal file
36
internal/ai/vision/model_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
38
internal/ai/vision/model_types.go
Normal file
38
internal/ai/vision/model_types.go
Normal 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
|
||||
}
|
||||
42
internal/ai/vision/models.go
Normal file
42
internal/ai/vision/models.go
Normal 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}
|
||||
)
|
||||
71
internal/ai/vision/nsfw.go
Normal file
71
internal/ai/vision/nsfw.go
Normal 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
Reference in New Issue
Block a user