Merge pull request #197 from photoview/face-detection
Implement Face Detection
This commit is contained in:
commit
a156af40e2
|
@ -16,7 +16,7 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build and deploy docker images
|
name: Build and deploy docker images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Analyze
|
name: Analyze
|
||||||
if: github.repository == 'photoview/photoview'
|
if: github.repository == 'photoview/photoview'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
|
@ -9,7 +9,7 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
test-api:
|
test-api:
|
||||||
name: Test API
|
name: Test API
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
@ -25,7 +25,12 @@ jobs:
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Get dependencies
|
- name: Get C dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libdlib-dev libblas-dev liblapack-dev libjpeg-turbo8-dev
|
||||||
|
|
||||||
|
- name: Get GO dependencies
|
||||||
run: |
|
run: |
|
||||||
go get -v -t -d ./...
|
go get -v -t -d ./...
|
||||||
if [ -f Gopkg.toml ]; then
|
if [ -f Gopkg.toml ]; then
|
||||||
|
|
25
Dockerfile
25
Dockerfile
|
@ -22,11 +22,25 @@ RUN npm run build -- --public-url $UI_PUBLIC_URL
|
||||||
### Build API ###
|
### Build API ###
|
||||||
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.15-buster AS api
|
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.15-buster AS api
|
||||||
|
|
||||||
# Install GCC cross compilers
|
# Install G++/GCC cross compilers
|
||||||
|
RUN dpkg --add-architecture arm64
|
||||||
|
RUN dpkg --add-architecture armhf
|
||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
|
RUN apt-get install -y g++-aarch64-linux-gnu libc6-dev-arm64-cross g++-arm-linux-gnueabihf libc6-dev-armhf-cross
|
||||||
|
|
||||||
COPY --from=tonistiigi/xx:golang / /
|
# Install go-face dependencies
|
||||||
|
RUN apt-get install -y \
|
||||||
|
libdlib-dev libdlib-dev:arm64 libdlib-dev:armhf \
|
||||||
|
libblas-dev libblas-dev:arm64 libblas-dev:armhf \
|
||||||
|
liblapack-dev liblapack-dev:arm64 liblapack-dev:armhf \
|
||||||
|
libjpeg62-turbo-dev libjpeg62-turbo-dev:arm64 libjpeg62-turbo-dev:armhf
|
||||||
|
|
||||||
|
RUN echo $PATH
|
||||||
|
|
||||||
|
COPY docker/go_wrapper.sh /go/bin/go
|
||||||
|
RUN chmod +x /go/bin/go
|
||||||
|
|
||||||
|
ENV CGO_ENABLED 1
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
RUN go env
|
RUN go env
|
||||||
|
@ -38,8 +52,11 @@ WORKDIR /app
|
||||||
COPY api/go.mod api/go.sum /app/
|
COPY api/go.mod api/go.sum /app/
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
# Build go-face
|
||||||
|
RUN sed -i 's/-march=native//g' /go/pkg/mod/github.com/!kagami/go-face*/face.go
|
||||||
|
RUN go install github.com/Kagami/go-face
|
||||||
|
|
||||||
# Build go-sqlite3 dependency with CGO
|
# Build go-sqlite3 dependency with CGO
|
||||||
ENV CGO_ENABLED 1
|
|
||||||
RUN go install github.com/mattn/go-sqlite3
|
RUN go install github.com/mattn/go-sqlite3
|
||||||
|
|
||||||
# Copy api source
|
# Copy api source
|
||||||
|
|
15
README.md
15
README.md
|
@ -35,6 +35,7 @@ Password: **demo**
|
||||||
- **Sharing**. Albums, as well as individual media, can easily be shared with a public link, the link can optinally be password protected.
|
- **Sharing**. Albums, as well as individual media, can easily be shared with a public link, the link can optinally be password protected.
|
||||||
- **Made for photography**. Photoview is built with photographers in mind, and thus supports **RAW** file formats, and **EXIF** parsing.
|
- **Made for photography**. Photoview is built with photographers in mind, and thus supports **RAW** file formats, and **EXIF** parsing.
|
||||||
- **Video support**. Many common video formats are supported. Videos will automatically be optimized for web.
|
- **Video support**. Many common video formats are supported. Videos will automatically be optimized for web.
|
||||||
|
- **Face recognition**. Faces will automatically be detected in photos, and photos of the same person will be grouped together.
|
||||||
- **Performant**. Thumbnails are automatically generated and photos first load when they are visible on the screen. In full screen, thumbnails are displayed until the high resolution image has been fully loaded.
|
- **Performant**. Thumbnails are automatically generated and photos first load when they are visible on the screen. In full screen, thumbnails are displayed until the high resolution image has been fully loaded.
|
||||||
- **Secure**. All media resources are protected with a cookie-token, all passwords are properly hashed, and the API uses a strict [CORS policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS).
|
- **Secure**. All media resources are protected with a cookie-token, all passwords are properly hashed, and the API uses a strict [CORS policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS).
|
||||||
|
|
||||||
|
@ -107,6 +108,20 @@ The photos will have to be scanned before they show up, you can start a scan man
|
||||||
### Start API server
|
### Start API server
|
||||||
|
|
||||||
Make sure [golang](https://golang.org/) is installed.
|
Make sure [golang](https://golang.org/) is installed.
|
||||||
|
|
||||||
|
Some C libraries are needed to compile the API, see [go-face requirements](https://github.com/Kagami/go-face#requirements) for more details.
|
||||||
|
They can be installed as shown below:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Ubuntu
|
||||||
|
sudo apt-get install libdlib-dev libblas-dev liblapack-dev libjpeg-turbo8-dev
|
||||||
|
# Debian
|
||||||
|
sudo apt-get install libdlib-dev libblas-dev liblapack-dev libjpeg62-turbo-dev
|
||||||
|
# macOS
|
||||||
|
brew install dlib
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
Then run the following commands:
|
Then run the following commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -164,6 +164,10 @@ func MigrateDatabase(db *gorm.DB) error {
|
||||||
&models.VideoMetadata{},
|
&models.VideoMetadata{},
|
||||||
&models.ShareToken{},
|
&models.ShareToken{},
|
||||||
&models.UserMediaData{},
|
&models.UserMediaData{},
|
||||||
|
|
||||||
|
// Face detection
|
||||||
|
&models.FaceGroup{},
|
||||||
|
&models.ImageFace{},
|
||||||
)
|
)
|
||||||
|
|
||||||
// v2.1.0 - Replaced by Media.CreatedAt
|
// v2.1.0 - Replaced by Media.CreatedAt
|
||||||
|
|
|
@ -4,6 +4,7 @@ go 1.13
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/99designs/gqlgen v0.13.0
|
github.com/99designs/gqlgen v0.13.0
|
||||||
|
github.com/Kagami/go-face v0.0.0-20200825065730-3dd2d74dccfb
|
||||||
github.com/agnivade/levenshtein v1.1.0 // indirect
|
github.com/agnivade/levenshtein v1.1.0 // indirect
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/gorilla/handlers v1.5.1
|
github.com/gorilla/handlers v1.5.1
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
|
github.com/99designs/gqlgen v0.13.0 h1:haLTcUp3Vwp80xMVEg5KRNwzfUrgFdRmtBY8fuB8scA=
|
||||||
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
|
github.com/99designs/gqlgen v0.13.0/go.mod h1:NV130r6f4tpRWuAI+zsrSdooO/eWUv+Gyyoi3rEfXIk=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/Kagami/go-face v0.0.0-20200825065730-3dd2d74dccfb h1:DXwA1Te9paM+nsdTGc7uve37lq7WEbQO+gwGBPVwQuQ=
|
||||||
|
github.com/Kagami/go-face v0.0.0-20200825065730-3dd2d74dccfb/go.mod h1:9wdDJkRgo3SGTcFwbQ7elVIQhIr2bbBjecuY7VoqmPU=
|
||||||
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
|
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
|
||||||
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
|
github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs=
|
||||||
github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM=
|
github.com/agnivade/levenshtein v1.1.0 h1:n6qGwyHG61v3ABce1rPVZklEYRT8NFpCMrpZdBUbYGM=
|
||||||
|
|
|
@ -32,6 +32,8 @@ models:
|
||||||
fields:
|
fields:
|
||||||
exif:
|
exif:
|
||||||
resolver: true
|
resolver: true
|
||||||
|
faces:
|
||||||
|
resolver: true
|
||||||
MediaURL:
|
MediaURL:
|
||||||
model: github.com/photoview/photoview/api/graphql/models.MediaURL
|
model: github.com/photoview/photoview/api/graphql/models.MediaURL
|
||||||
MediaEXIF:
|
MediaEXIF:
|
||||||
|
@ -42,5 +44,14 @@ models:
|
||||||
model: github.com/photoview/photoview/api/graphql/models.Album
|
model: github.com/photoview/photoview/api/graphql/models.Album
|
||||||
ShareToken:
|
ShareToken:
|
||||||
model: github.com/photoview/photoview/api/graphql/models.ShareToken
|
model: github.com/photoview/photoview/api/graphql/models.ShareToken
|
||||||
|
FaceGroup:
|
||||||
|
model: github.com/photoview/photoview/api/graphql/models.FaceGroup
|
||||||
|
ImageFace:
|
||||||
|
model: github.com/photoview/photoview/api/graphql/models.ImageFace
|
||||||
|
fields:
|
||||||
|
faceGroup:
|
||||||
|
resolver: true
|
||||||
|
FaceRectangle:
|
||||||
|
model: github.com/photoview/photoview/api/graphql/models.FaceRectangle
|
||||||
SiteInfo:
|
SiteInfo:
|
||||||
model: github.com/photoview/photoview/api/graphql/models.SiteInfo
|
model: github.com/photoview/photoview/api/graphql/models.SiteInfo
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,120 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/Kagami/go-face"
|
||||||
|
"github.com/photoview/photoview/api/scanner/image_helpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FaceGroup struct {
|
||||||
|
Model
|
||||||
|
Label *string
|
||||||
|
ImageFaces []ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageFace struct {
|
||||||
|
Model
|
||||||
|
FaceGroupID int `gorm:"not null;index"`
|
||||||
|
FaceGroup *FaceGroup
|
||||||
|
MediaID int `gorm:"not null;index"`
|
||||||
|
Media Media `gorm:"constraint:OnDelete:CASCADE;"`
|
||||||
|
Descriptor FaceDescriptor `gorm:"not null"`
|
||||||
|
Rectangle FaceRectangle `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FaceDescriptor face.Descriptor
|
||||||
|
|
||||||
|
// GormDataType datatype used in database
|
||||||
|
func (fd FaceDescriptor) GormDataType() string {
|
||||||
|
return "BLOB"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan tells GORM how to convert database data to Go format
|
||||||
|
func (fd *FaceDescriptor) Scan(value interface{}) error {
|
||||||
|
byteValue := value.([]byte)
|
||||||
|
reader := bytes.NewReader(byteValue)
|
||||||
|
binary.Read(reader, binary.LittleEndian, fd)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value tells GORM how to save into the database
|
||||||
|
func (fd FaceDescriptor) Value() (driver.Value, error) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if err := binary.Write(buf, binary.LittleEndian, fd); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type FaceRectangle struct {
|
||||||
|
MinX, MaxX float64
|
||||||
|
MinY, MaxY float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToDBFaceRectangle converts a pixel absolute rectangle to a relative FaceRectangle to be saved in the database
|
||||||
|
func ToDBFaceRectangle(imgRec image.Rectangle, imagePath string) (*FaceRectangle, error) {
|
||||||
|
size, err := image_helpers.GetPhotoDimensions(imagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FaceRectangle{
|
||||||
|
MinX: float64(imgRec.Min.X) / float64(size.Width),
|
||||||
|
MaxX: float64(imgRec.Max.X) / float64(size.Width),
|
||||||
|
MinY: float64(imgRec.Min.Y) / float64(size.Height),
|
||||||
|
MaxY: float64(imgRec.Max.Y) / float64(size.Height),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormDataType datatype used in database
|
||||||
|
func (fr FaceRectangle) GormDataType() string {
|
||||||
|
return "VARCHAR(64)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan tells GORM how to convert database data to Go format
|
||||||
|
func (fr *FaceRectangle) Scan(value interface{}) error {
|
||||||
|
byteArray := value.([]uint8)
|
||||||
|
slices := strings.Split(string(byteArray), ":")
|
||||||
|
|
||||||
|
if len(slices) != 4 {
|
||||||
|
return fmt.Errorf("Invalid face rectangle format, expected 4 values, got %d", len(slices))
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
fr.MinX, err = strconv.ParseFloat(slices[0], 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fr.MaxX, err = strconv.ParseFloat(slices[1], 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fr.MinY, err = strconv.ParseFloat(slices[2], 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fr.MaxY, err = strconv.ParseFloat(slices[3], 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value tells GORM how to save into the database
|
||||||
|
func (fr FaceRectangle) Value() (driver.Value, error) {
|
||||||
|
result := fmt.Sprintf("%f:%f:%f:%f", fr.MinX, fr.MaxX, fr.MinY, fr.MaxY)
|
||||||
|
return result, nil
|
||||||
|
}
|
|
@ -1,11 +1,14 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/utils"
|
"github.com/photoview/photoview/api/utils"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -25,6 +28,7 @@ type Media struct {
|
||||||
VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:CASCADE;"`
|
VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:CASCADE;"`
|
||||||
SideCarPath *string
|
SideCarPath *string
|
||||||
SideCarHash *string `gorm:"unique"`
|
SideCarHash *string `gorm:"unique"`
|
||||||
|
Faces []*ImageFace `gorm:"constraint:OnDelete:CASCADE;"`
|
||||||
|
|
||||||
// Only used internally
|
// Only used internally
|
||||||
CounterpartPath *string `gorm:"-"`
|
CounterpartPath *string `gorm:"-"`
|
||||||
|
@ -59,7 +63,7 @@ const (
|
||||||
type MediaURL struct {
|
type MediaURL struct {
|
||||||
Model
|
Model
|
||||||
MediaID int `gorm:"not null;index"`
|
MediaID int `gorm:"not null;index"`
|
||||||
Media Media `gorm:"constraint:OnDelete:CASCADE;"`
|
Media *Media `gorm:"constraint:OnDelete:CASCADE;"`
|
||||||
MediaName string `gorm:"not null"`
|
MediaName string `gorm:"not null"`
|
||||||
Width int `gorm:"not null"`
|
Width int `gorm:"not null"`
|
||||||
Height int `gorm:"not null"`
|
Height int `gorm:"not null"`
|
||||||
|
@ -80,6 +84,24 @@ func (p *MediaURL) URL() string {
|
||||||
return imageUrl.String()
|
return imageUrl.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *MediaURL) CachedPath() (string, error) {
|
||||||
|
var cachedPath string
|
||||||
|
|
||||||
|
if p.Media == nil {
|
||||||
|
return "", errors.New("mediaURL.Media is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Purpose == PhotoThumbnail || p.Purpose == PhotoHighRes || p.Purpose == VideoThumbnail {
|
||||||
|
cachedPath = path.Join(utils.MediaCachePath(), strconv.Itoa(int(p.Media.AlbumID)), strconv.Itoa(int(p.MediaID)), p.MediaName)
|
||||||
|
} else if p.Purpose == MediaOriginal {
|
||||||
|
cachedPath = p.Media.Path
|
||||||
|
} else {
|
||||||
|
return "", errors.New(fmt.Sprintf("cannot determine cache path for purpose (%s)", p.Purpose))
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
func SanitizeMediaName(mediaName string) string {
|
func SanitizeMediaName(mediaName string) string {
|
||||||
result := mediaName
|
result := mediaName
|
||||||
result = strings.ReplaceAll(result, "/", "")
|
result = strings.ReplaceAll(result, "/", "")
|
||||||
|
|
|
@ -0,0 +1,347 @@
|
||||||
|
package resolvers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
api "github.com/photoview/photoview/api/graphql"
|
||||||
|
"github.com/photoview/photoview/api/graphql/auth"
|
||||||
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type imageFaceResolver struct {
|
||||||
|
*Resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Resolver) ImageFace() api.ImageFaceResolver {
|
||||||
|
return imageFaceResolver{r}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r imageFaceResolver) FaceGroup(ctx context.Context, obj *models.ImageFace) (*models.FaceGroup, error) {
|
||||||
|
if obj.FaceGroup != nil {
|
||||||
|
return obj.FaceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var faceGroup models.FaceGroup
|
||||||
|
if err := r.Database.Model(&obj).Association("FaceGroup").Find(&faceGroup); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.FaceGroup = &faceGroup
|
||||||
|
|
||||||
|
return &faceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagination) ([]*models.FaceGroup, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.FillAlbums(r.Database); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userAlbumIDs := make([]int, len(user.Albums))
|
||||||
|
for i, album := range user.Albums {
|
||||||
|
userAlbumIDs[i] = album.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
imageFaceQuery := r.Database.
|
||||||
|
Joins("Media").
|
||||||
|
Where("media.album_id IN (?)", userAlbumIDs)
|
||||||
|
|
||||||
|
var imageFaces []*models.ImageFace
|
||||||
|
if err := imageFaceQuery.Find(&imageFaces).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
faceGroupMap := make(map[int][]models.ImageFace)
|
||||||
|
for _, face := range imageFaces {
|
||||||
|
_, found := faceGroupMap[face.FaceGroupID]
|
||||||
|
|
||||||
|
if found {
|
||||||
|
faceGroupMap[face.FaceGroupID] = append(faceGroupMap[face.FaceGroupID], *face)
|
||||||
|
} else {
|
||||||
|
faceGroupMap[face.FaceGroupID] = make([]models.ImageFace, 1)
|
||||||
|
faceGroupMap[face.FaceGroupID][0] = *face
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
faceGroupIDs := make([]int, len(faceGroupMap))
|
||||||
|
i := 0
|
||||||
|
for groupID := range faceGroupMap {
|
||||||
|
faceGroupIDs[i] = groupID
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
faceGroupQuery := r.Database.
|
||||||
|
Joins("LEFT JOIN image_faces ON image_faces.id = face_groups.id").
|
||||||
|
Where("face_groups.id IN (?)", faceGroupIDs).
|
||||||
|
Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END")
|
||||||
|
|
||||||
|
var faceGroups []*models.FaceGroup
|
||||||
|
if err := faceGroupQuery.Find(&faceGroups).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, faceGroup := range faceGroups {
|
||||||
|
faceGroup.ImageFaces = faceGroupMap[faceGroup.ID]
|
||||||
|
}
|
||||||
|
|
||||||
|
return faceGroups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
faceGroup, err := userOwnedFaceGroup(r.Database, user, faceGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.Database.Model(faceGroup).Update("label", label).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return faceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationFaceGroup, err := userOwnedFaceGroup(r.Database, user, destinationFaceGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceFaceGroup, err := userOwnedFaceGroup(r.Database, user, sourceFaceGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updateError := r.Database.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&models.ImageFace{}).Where("face_group_id = ?", sourceFaceGroup.ID).Update("face_group_id", destinationFaceGroup.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Delete(&sourceFaceGroup).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if updateError != nil {
|
||||||
|
return nil, updateError
|
||||||
|
}
|
||||||
|
|
||||||
|
face_detection.GlobalFaceDetector.MergeCategories(int32(sourceFaceGroupID), int32(destinationFaceGroupID))
|
||||||
|
|
||||||
|
return destinationFaceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) MoveImageFaces(ctx context.Context, imageFaceIDs []int, destinationFaceGroupID int) (*models.FaceGroup, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
userOwnedImageFaceIDs := make([]int, 0)
|
||||||
|
var destFaceGroup *models.FaceGroup
|
||||||
|
|
||||||
|
transErr := r.Database.Transaction(func(tx *gorm.DB) error {
|
||||||
|
|
||||||
|
var err error
|
||||||
|
destFaceGroup, err = userOwnedFaceGroup(tx, user, destinationFaceGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, imageFace := range userOwnedImageFaces {
|
||||||
|
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceFaceGroups []*models.FaceGroup
|
||||||
|
if err := tx.
|
||||||
|
Joins("LEFT JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||||
|
Where("image_faces.id IN (?)", userOwnedImageFaceIDs).
|
||||||
|
Find(&sourceFaceGroups).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.
|
||||||
|
Model(&models.ImageFace{}).
|
||||||
|
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||||
|
Update("face_group_id", destFaceGroup.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete face groups if they have become empty
|
||||||
|
for _, faceGroup := range sourceFaceGroups {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&models.ImageFace{}).Where("face_group_id = ?", faceGroup.ID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
if err := tx.Delete(&faceGroup).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if transErr != nil {
|
||||||
|
return nil, transErr
|
||||||
|
}
|
||||||
|
|
||||||
|
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(destFaceGroup.ID))
|
||||||
|
|
||||||
|
return destFaceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatedImageFaces []*models.ImageFace
|
||||||
|
|
||||||
|
transactionError := r.Database.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
updatedImageFaces, err = face_detection.GlobalFaceDetector.RecognizeUnlabeledFaces(tx, user)
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
if transactionError != nil {
|
||||||
|
return nil, transactionError
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedImageFaces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mutationResolver) DetachImageFaces(ctx context.Context, imageFaceIDs []int) (*models.FaceGroup, error) {
|
||||||
|
user := auth.UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
userOwnedImageFaceIDs := make([]int, 0)
|
||||||
|
newFaceGroup := models.FaceGroup{}
|
||||||
|
|
||||||
|
transactionError := r.Database.Transaction(func(tx *gorm.DB) error {
|
||||||
|
|
||||||
|
userOwnedImageFaces, err := getUserOwnedImageFaces(tx, user, imageFaceIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, imageFace := range userOwnedImageFaces {
|
||||||
|
userOwnedImageFaceIDs = append(userOwnedImageFaceIDs, imageFace.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Save(&newFaceGroup).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.
|
||||||
|
Model(&models.ImageFace{}).
|
||||||
|
Where("id IN (?)", userOwnedImageFaceIDs).
|
||||||
|
Update("face_group_id", newFaceGroup.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if transactionError != nil {
|
||||||
|
return nil, transactionError
|
||||||
|
}
|
||||||
|
|
||||||
|
face_detection.GlobalFaceDetector.MergeImageFaces(userOwnedImageFaceIDs, int32(newFaceGroup.ID))
|
||||||
|
|
||||||
|
return &newFaceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userOwnedFaceGroup(db *gorm.DB, user *models.User, faceGroupID int) (*models.FaceGroup, error) {
|
||||||
|
if user.Admin {
|
||||||
|
var faceGroup models.FaceGroup
|
||||||
|
if err := db.Where("id = ?", faceGroupID).Find(&faceGroup).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &faceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.FillAlbums(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userAlbumIDs := make([]int, len(user.Albums))
|
||||||
|
for i, album := range user.Albums {
|
||||||
|
userAlbumIDs[i] = album.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that user owns at leat one of the images in the face group
|
||||||
|
imageFaceQuery := db.
|
||||||
|
Select("image_faces.id").
|
||||||
|
Table("image_faces").
|
||||||
|
Joins("LEFT JOIN media ON media.id = image_faces.media_id").
|
||||||
|
Where("media.album_id IN (?)", userAlbumIDs)
|
||||||
|
|
||||||
|
faceGroupQuery := db.
|
||||||
|
Model(&models.FaceGroup{}).
|
||||||
|
Joins("JOIN image_faces ON face_groups.id = image_faces.face_group_id").
|
||||||
|
Where("face_groups.id = ?", faceGroupID).
|
||||||
|
Where("image_faces.id IN (?)", imageFaceQuery)
|
||||||
|
|
||||||
|
var faceGroup models.FaceGroup
|
||||||
|
if err := faceGroupQuery.Find(&faceGroup).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.Wrap(err, "face group does not exist or is not owned by the user")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &faceGroup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserOwnedImageFaces(tx *gorm.DB, user *models.User, imageFaceIDs []int) ([]*models.ImageFace, error) {
|
||||||
|
if err := user.FillAlbums(tx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userAlbumIDs := make([]int, len(user.Albums))
|
||||||
|
for i, album := range user.Albums {
|
||||||
|
userAlbumIDs[i] = album.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var userOwnedImageFaces []*models.ImageFace
|
||||||
|
if err := tx.
|
||||||
|
Joins("JOIN media ON media.id = image_faces.media_id").
|
||||||
|
Where("media.album_id IN (?)", userAlbumIDs).
|
||||||
|
Where("image_faces.id IN (?)", imageFaceIDs).
|
||||||
|
Find(&userOwnedImageFaces).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return userOwnedImageFaces, nil
|
||||||
|
}
|
|
@ -208,3 +208,16 @@ func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favor
|
||||||
|
|
||||||
return &media, nil
|
return &media, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *mediaResolver) Faces(ctx context.Context, media *models.Media) ([]*models.ImageFace, error) {
|
||||||
|
if media.Faces != nil {
|
||||||
|
return media.Faces, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var faces []*models.ImageFace
|
||||||
|
if err := r.Database.Model(&media).Association("Faces").Find(&faces); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return faces, nil
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/photoview/photoview/api/graphql/auth"
|
"github.com/photoview/photoview/api/graphql/auth"
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
"github.com/photoview/photoview/api/scanner"
|
"github.com/photoview/photoview/api/scanner"
|
||||||
|
"github.com/photoview/photoview/api/utils"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
@ -252,7 +253,7 @@ func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User
|
||||||
|
|
||||||
// If there is only one associated user, clean up the cache folder and delete the album row
|
// If there is only one associated user, clean up the cache folder and delete the album row
|
||||||
for _, deletedAlbumID := range deletedAlbumIDs {
|
for _, deletedAlbumID := range deletedAlbumIDs {
|
||||||
cachePath := path.Join(scanner.MediaCachePath(), strconv.Itoa(int(deletedAlbumID)))
|
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(deletedAlbumID)))
|
||||||
if err := os.RemoveAll(cachePath); err != nil {
|
if err := os.RemoveAll(cachePath); err != nil {
|
||||||
return &user, err
|
return &user, err
|
||||||
}
|
}
|
||||||
|
@ -370,7 +371,7 @@ func (r *mutationResolver) UserRemoveRootAlbum(ctx context.Context, userID int,
|
||||||
if deletedAlbumIDs != nil {
|
if deletedAlbumIDs != nil {
|
||||||
// Delete albums from cache
|
// Delete albums from cache
|
||||||
for _, id := range deletedAlbumIDs {
|
for _, id := range deletedAlbumIDs {
|
||||||
cacheAlbumPath := path.Join(scanner.MediaCachePath(), strconv.Itoa(id))
|
cacheAlbumPath := path.Join(utils.MediaCachePath(), strconv.Itoa(id))
|
||||||
|
|
||||||
if err := os.RemoveAll(cacheAlbumPath); err != nil {
|
if err := os.RemoveAll(cacheAlbumPath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
@ -59,6 +59,8 @@ type Query {
|
||||||
shareTokenValidatePassword(token: String!, password: String): Boolean!
|
shareTokenValidatePassword(token: String!, password: String): Boolean!
|
||||||
|
|
||||||
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
|
search(query: String!, limitMedia: Int, limitAlbums: Int): SearchResult!
|
||||||
|
|
||||||
|
myFaceGroups(paginate: Pagination): [FaceGroup!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
@ -113,6 +115,17 @@ type Mutation {
|
||||||
|
|
||||||
"Set max number of concurrent scanner jobs running at once"
|
"Set max number of concurrent scanner jobs running at once"
|
||||||
setScannerConcurrentWorkers(workers: Int!): Int!
|
setScannerConcurrentWorkers(workers: Int!): Int!
|
||||||
|
|
||||||
|
"Assign a label to a face group, set label to null to remove the current one"
|
||||||
|
setFaceGroupLabel(faceGroupID: ID!, label: String): FaceGroup!
|
||||||
|
"Merge two face groups into a single one, all ImageFaces from source will be moved to destination"
|
||||||
|
combineFaceGroups(destinationFaceGroupID: ID!, sourceFaceGroupID: ID!): FaceGroup!
|
||||||
|
"Move a list of ImageFaces to another face group"
|
||||||
|
moveImageFaces(imageFaceIDs: [ID!]!, destinationFaceGroupID: ID!): FaceGroup!
|
||||||
|
"Check all unlabeled faces to see if they match a labeled FaceGroup, and move them if they match"
|
||||||
|
recognizeUnlabeledFaces: [ImageFace!]!
|
||||||
|
"Move a list of ImageFaces to a new face group"
|
||||||
|
detachImageFaces(imageFaceIDs: [ID!]!): FaceGroup!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subscription {
|
type Subscription {
|
||||||
|
@ -262,6 +275,8 @@ type Media {
|
||||||
|
|
||||||
shares: [ShareToken!]!
|
shares: [ShareToken!]!
|
||||||
downloads: [MediaDownload!]!
|
downloads: [MediaDownload!]!
|
||||||
|
|
||||||
|
faces: [ImageFace!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
"EXIF metadata from the camera"
|
"EXIF metadata from the camera"
|
||||||
|
@ -314,3 +329,23 @@ type TimelineGroup {
|
||||||
mediaTotal: Int!
|
mediaTotal: Int!
|
||||||
date: Time!
|
date: Time!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FaceGroup {
|
||||||
|
id: ID!
|
||||||
|
label: String
|
||||||
|
imageFaces: [ImageFace!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageFace {
|
||||||
|
id: ID!
|
||||||
|
media: Media!
|
||||||
|
rectangle: FaceRectangle
|
||||||
|
faceGroup: FaceGroup!
|
||||||
|
}
|
||||||
|
|
||||||
|
type FaceRectangle {
|
||||||
|
minX: Float!
|
||||||
|
maxX: Float!
|
||||||
|
minY: Float!
|
||||||
|
maxY: Float!
|
||||||
|
}
|
||||||
|
|
|
@ -4,8 +4,6 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
@ -27,7 +25,7 @@ func RegisterPhotoRoutes(db *gorm.DB, router *mux.Router) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
media := &mediaURL.Media
|
media := mediaURL.Media
|
||||||
|
|
||||||
if success, response, status, err := authenticateMedia(media, db, r); !success {
|
if success, response, status, err := authenticateMedia(media, db, r); !success {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -38,14 +36,9 @@ func RegisterPhotoRoutes(db *gorm.DB, router *mux.Router) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var cachedPath string
|
cachedPath, err := mediaURL.CachedPath()
|
||||||
|
if err != nil {
|
||||||
if mediaURL.Purpose == models.PhotoThumbnail || mediaURL.Purpose == models.PhotoHighRes || mediaURL.Purpose == models.VideoThumbnail {
|
log.Printf("ERROR: %s\n", err)
|
||||||
cachedPath = path.Join(scanner.MediaCachePath(), strconv.Itoa(int(media.AlbumID)), strconv.Itoa(int(mediaURL.MediaID)), mediaURL.MediaName)
|
|
||||||
} else if mediaURL.Purpose == models.MediaOriginal {
|
|
||||||
cachedPath = media.Path
|
|
||||||
} else {
|
|
||||||
log.Printf("ERROR: Can not handle media_purpose for photo: %s\n", mediaURL.Purpose)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte("internal server error"))
|
w.Write([]byte("internal server error"))
|
||||||
return
|
return
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
"github.com/photoview/photoview/api/scanner"
|
"github.com/photoview/photoview/api/scanner"
|
||||||
|
"github.com/photoview/photoview/api/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -26,7 +27,7 @@ func RegisterVideoRoutes(db *gorm.DB, router *mux.Router) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var media = &mediaURL.Media
|
var media = mediaURL.Media
|
||||||
|
|
||||||
if success, response, status, err := authenticateMedia(media, db, r); !success {
|
if success, response, status, err := authenticateMedia(media, db, r); !success {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -40,7 +41,7 @@ func RegisterVideoRoutes(db *gorm.DB, router *mux.Router) {
|
||||||
var cachedPath string
|
var cachedPath string
|
||||||
|
|
||||||
if mediaURL.Purpose == models.VideoWeb {
|
if mediaURL.Purpose == models.VideoWeb {
|
||||||
cachedPath = path.Join(scanner.MediaCachePath(), strconv.Itoa(int(media.AlbumID)), strconv.Itoa(int(mediaURL.MediaID)), mediaURL.MediaName)
|
cachedPath = path.Join(utils.MediaCachePath(), strconv.Itoa(int(media.AlbumID)), strconv.Itoa(int(mediaURL.MediaID)), mediaURL.MediaName)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("ERROR: Can not handle media_purpose for video: %s\n", mediaURL.Purpose)
|
log.Printf("ERROR: Can not handle media_purpose for video: %s\n", mediaURL.Purpose)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"github.com/photoview/photoview/api/utils"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
@ -36,7 +37,7 @@ func CleanupMedia(db *gorm.DB, albumId int, albumMedia []*models.Media) []error
|
||||||
for _, media := range mediaList {
|
for _, media := range mediaList {
|
||||||
|
|
||||||
mediaIDs = append(mediaIDs, media.ID)
|
mediaIDs = append(mediaIDs, media.ID)
|
||||||
cachePath := path.Join(MediaCachePath(), strconv.Itoa(int(albumId)), strconv.Itoa(int(media.ID)))
|
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(albumId)), strconv.Itoa(int(media.ID)))
|
||||||
err := os.RemoveAll(cachePath)
|
err := os.RemoveAll(cachePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
|
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
|
||||||
|
@ -90,7 +91,7 @@ func deleteOldUserAlbums(db *gorm.DB, scannedAlbums []*models.Album, user *model
|
||||||
deleteAlbumIDs := make([]int, len(deleteAlbums))
|
deleteAlbumIDs := make([]int, len(deleteAlbums))
|
||||||
for i, album := range deleteAlbums {
|
for i, album := range deleteAlbums {
|
||||||
deleteAlbumIDs[i] = album.ID
|
deleteAlbumIDs[i] = album.ID
|
||||||
cachePath := path.Join(MediaCachePath(), strconv.Itoa(int(album.ID)))
|
cachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(album.ID)))
|
||||||
err := os.RemoveAll(cachePath)
|
err := os.RemoveAll(cachePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
|
deleteErrors = append(deleteErrors, errors.Wrapf(err, "delete unused cache folder (%s)", cachePath))
|
||||||
|
|
|
@ -6,18 +6,14 @@ import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"github.com/photoview/photoview/api/scanner/image_helpers"
|
||||||
"github.com/photoview/photoview/api/utils"
|
"github.com/photoview/photoview/api/utils"
|
||||||
|
"github.com/pkg/errors"
|
||||||
"gopkg.in/vansante/go-ffprobe.v2"
|
"gopkg.in/vansante/go-ffprobe.v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PhotoDimensions struct {
|
|
||||||
Width int
|
|
||||||
Height int
|
|
||||||
}
|
|
||||||
|
|
||||||
func DecodeImage(imagePath string) (image.Image, error) {
|
func DecodeImage(imagePath string) (image.Image, error) {
|
||||||
file, err := os.Open(imagePath)
|
file, err := os.Open(imagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -33,32 +29,6 @@ func DecodeImage(imagePath string) (image.Image, error) {
|
||||||
return image, nil
|
return image, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func PhotoDimensionsFromRect(rect image.Rectangle) PhotoDimensions {
|
|
||||||
return PhotoDimensions{
|
|
||||||
Width: rect.Bounds().Max.X,
|
|
||||||
Height: rect.Bounds().Max.Y,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dimensions *PhotoDimensions) ThumbnailScale() PhotoDimensions {
|
|
||||||
aspect := float64(dimensions.Width) / float64(dimensions.Height)
|
|
||||||
|
|
||||||
var width, height int
|
|
||||||
|
|
||||||
if aspect > 1 {
|
|
||||||
width = 1024
|
|
||||||
height = int(1024 / aspect)
|
|
||||||
} else {
|
|
||||||
width = int(1024 * aspect)
|
|
||||||
height = 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
return PhotoDimensions{
|
|
||||||
Width: width,
|
|
||||||
Height: height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EncodeMediaData is used to easily decode media data, with a cache so expensive operations are not repeated
|
// EncodeMediaData is used to easily decode media data, with a cache so expensive operations are not repeated
|
||||||
type EncodeMediaData struct {
|
type EncodeMediaData struct {
|
||||||
media *models.Media
|
media *models.Media
|
||||||
|
@ -83,24 +53,6 @@ func EncodeImageJPEG(image image.Image, outputPath string, jpegQuality int) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPhotoDimensions(imagePath string) (*PhotoDimensions, error) {
|
|
||||||
photoFile, err := os.Open(imagePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer photoFile.Close()
|
|
||||||
|
|
||||||
config, _, err := image.DecodeConfig(photoFile)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PhotoDimensions{
|
|
||||||
Width: config.Width,
|
|
||||||
Height: config.Height,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContentType reads the image to determine its content type
|
// ContentType reads the image to determine its content type
|
||||||
func (img *EncodeMediaData) ContentType() (*MediaType, error) {
|
func (img *EncodeMediaData) ContentType() (*MediaType, error) {
|
||||||
if img._contentType != nil {
|
if img._contentType != nil {
|
||||||
|
@ -148,13 +100,13 @@ func (img *EncodeMediaData) EncodeHighRes(tx *gorm.DB, outputPath string) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func EncodeThumbnail(inputPath string, outputPath string) (*PhotoDimensions, error) {
|
func EncodeThumbnail(inputPath string, outputPath string) (*image_helpers.PhotoDimensions, error) {
|
||||||
inputImage, err := DecodeImage(inputPath)
|
inputImage, err := DecodeImage(inputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dimensions := PhotoDimensionsFromRect(inputImage.Bounds())
|
dimensions := image_helpers.PhotoDimensionsFromRect(inputImage.Bounds())
|
||||||
dimensions = dimensions.ThumbnailScale()
|
dimensions = dimensions.ThumbnailScale()
|
||||||
|
|
||||||
thumbImage := imaging.Resize(inputImage, dimensions.Width, dimensions.Height, imaging.NearestNeighbor)
|
thumbImage := imaging.Resize(inputImage, dimensions.Width, dimensions.Height, imaging.NearestNeighbor)
|
||||||
|
|
|
@ -0,0 +1,281 @@
|
||||||
|
package face_detection
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/Kagami/go-face"
|
||||||
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FaceDetector struct {
|
||||||
|
mutex sync.Mutex
|
||||||
|
db *gorm.DB
|
||||||
|
rec *face.Recognizer
|
||||||
|
faceDescriptors []face.Descriptor
|
||||||
|
faceGroupIDs []int32
|
||||||
|
imageFaceIDs []int
|
||||||
|
}
|
||||||
|
|
||||||
|
var GlobalFaceDetector FaceDetector
|
||||||
|
|
||||||
|
func InitializeFaceDetector(db *gorm.DB) error {
|
||||||
|
|
||||||
|
log.Println("Initializing face detector")
|
||||||
|
|
||||||
|
rec, err := face.NewRecognizer(filepath.Join("data", "models"))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "initialize facedetect recognizer")
|
||||||
|
}
|
||||||
|
|
||||||
|
faceDescriptors, faceGroupIDs, imageFaceIDs, err := getSamplesFromDatabase(db)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "get face detection samples from database")
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalFaceDetector = FaceDetector{
|
||||||
|
db: db,
|
||||||
|
rec: rec,
|
||||||
|
faceDescriptors: faceDescriptors,
|
||||||
|
faceGroupIDs: faceGroupIDs,
|
||||||
|
imageFaceIDs: imageFaceIDs,
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSamplesFromDatabase(db *gorm.DB) (samples []face.Descriptor, faceGroupIDs []int32, imageFaceIDs []int, err error) {
|
||||||
|
|
||||||
|
var imageFaces []*models.ImageFace
|
||||||
|
|
||||||
|
if err = db.Find(&imageFaces).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
samples = make([]face.Descriptor, len(imageFaces))
|
||||||
|
faceGroupIDs = make([]int32, len(imageFaces))
|
||||||
|
imageFaceIDs = make([]int, len(imageFaces))
|
||||||
|
|
||||||
|
for i, imgFace := range imageFaces {
|
||||||
|
samples[i] = face.Descriptor(imgFace.Descriptor)
|
||||||
|
faceGroupIDs[i] = int32(imgFace.FaceGroupID)
|
||||||
|
imageFaceIDs[i] = imgFace.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectFaces finds the faces in the given image and saves them to the database
|
||||||
|
func (fd *FaceDetector) DetectFaces(media *models.Media) error {
|
||||||
|
if err := fd.db.Model(media).Preload("MediaURL").First(&media).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var thumbnailURL *models.MediaURL
|
||||||
|
for _, url := range media.MediaURL {
|
||||||
|
if url.Purpose == models.PhotoThumbnail {
|
||||||
|
thumbnailURL = &url
|
||||||
|
thumbnailURL.Media = media
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if thumbnailURL == nil {
|
||||||
|
return errors.New("thumbnail url is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
thumbnailPath, err := thumbnailURL.CachedPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fd.mutex.Lock()
|
||||||
|
faces, err := fd.rec.RecognizeFile(thumbnailPath)
|
||||||
|
fd.mutex.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "error read faces")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, face := range faces {
|
||||||
|
fd.classifyFace(&face, media, thumbnailPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fd *FaceDetector) classifyDescriptor(descriptor face.Descriptor) int32 {
|
||||||
|
return int32(fd.rec.ClassifyThreshold(descriptor, 0.3))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fd *FaceDetector) classifyFace(face *face.Face, media *models.Media, imagePath string) error {
|
||||||
|
fd.mutex.Lock()
|
||||||
|
defer fd.mutex.Unlock()
|
||||||
|
|
||||||
|
match := fd.classifyDescriptor(face.Descriptor)
|
||||||
|
|
||||||
|
faceRect, err := models.ToDBFaceRectangle(face.Rectangle, imagePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
imageFace := models.ImageFace{
|
||||||
|
MediaID: media.ID,
|
||||||
|
Descriptor: models.FaceDescriptor(face.Descriptor),
|
||||||
|
Rectangle: *faceRect,
|
||||||
|
}
|
||||||
|
|
||||||
|
var faceGroup models.FaceGroup
|
||||||
|
|
||||||
|
// If no match add it new to samples
|
||||||
|
if match < 0 {
|
||||||
|
log.Println("No match, assigning new face")
|
||||||
|
|
||||||
|
faceGroup = models.FaceGroup{
|
||||||
|
ImageFaces: []models.ImageFace{imageFace},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fd.db.Create(&faceGroup).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
log.Println("Found match")
|
||||||
|
|
||||||
|
if err := fd.db.First(&faceGroup, int(match)).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fd.db.Model(&faceGroup).Association("ImageFaces").Append(&imageFace); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fd.faceDescriptors = append(fd.faceDescriptors, face.Descriptor)
|
||||||
|
fd.faceGroupIDs = append(fd.faceGroupIDs, int32(faceGroup.ID))
|
||||||
|
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFace.ID)
|
||||||
|
|
||||||
|
fd.rec.SetSamples(fd.faceDescriptors, fd.faceGroupIDs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fd *FaceDetector) MergeCategories(sourceID int32, destID int32) {
|
||||||
|
fd.mutex.Lock()
|
||||||
|
defer fd.mutex.Unlock()
|
||||||
|
|
||||||
|
for i := range fd.faceGroupIDs {
|
||||||
|
if fd.faceGroupIDs[i] == sourceID {
|
||||||
|
fd.faceGroupIDs[i] = destID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fd *FaceDetector) MergeImageFaces(imageFaceIDs []int, destFaceGroupID int32) {
|
||||||
|
fd.mutex.Lock()
|
||||||
|
defer fd.mutex.Unlock()
|
||||||
|
|
||||||
|
for i := range fd.faceGroupIDs {
|
||||||
|
imageFaceID := fd.imageFaceIDs[i]
|
||||||
|
|
||||||
|
for _, id := range imageFaceIDs {
|
||||||
|
if imageFaceID == id {
|
||||||
|
fd.faceGroupIDs[i] = destFaceGroupID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fd *FaceDetector) RecognizeUnlabeledFaces(tx *gorm.DB, user *models.User) ([]*models.ImageFace, error) {
|
||||||
|
unrecognizedDescriptors := make([]face.Descriptor, 0)
|
||||||
|
unrecognizedFaceGroupIDs := make([]int32, 0)
|
||||||
|
unrecognizedImageFaceIDs := make([]int, 0)
|
||||||
|
|
||||||
|
newFaceGroupIDs := make([]int32, 0)
|
||||||
|
newDescriptors := make([]face.Descriptor, 0)
|
||||||
|
newImageFaceIDs := make([]int, 0)
|
||||||
|
|
||||||
|
var unlabeledFaceGroups []*models.FaceGroup
|
||||||
|
|
||||||
|
err := tx.
|
||||||
|
Joins("JOIN image_faces ON image_faces.face_group_id = face_groups.id").
|
||||||
|
Joins("JOIN media ON image_faces.media_id = media.id").
|
||||||
|
Where("face_groups.label IS NULL").
|
||||||
|
Where("media.album_id IN (?)",
|
||||||
|
tx.Select("album_id").Table("user_albums").Where("user_id = ?", user.ID),
|
||||||
|
).
|
||||||
|
Find(&unlabeledFaceGroups).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fd.mutex.Lock()
|
||||||
|
defer fd.mutex.Unlock()
|
||||||
|
|
||||||
|
for i := range fd.faceDescriptors {
|
||||||
|
descriptor := fd.faceDescriptors[i]
|
||||||
|
faceGroupID := fd.faceGroupIDs[i]
|
||||||
|
imageFaceID := fd.imageFaceIDs[i]
|
||||||
|
|
||||||
|
isUnlabeled := false
|
||||||
|
for _, unlabeledFaceGroup := range unlabeledFaceGroups {
|
||||||
|
if faceGroupID == int32(unlabeledFaceGroup.ID) {
|
||||||
|
isUnlabeled = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isUnlabeled {
|
||||||
|
unrecognizedFaceGroupIDs = append(unrecognizedFaceGroupIDs, faceGroupID)
|
||||||
|
unrecognizedDescriptors = append(unrecognizedDescriptors, descriptor)
|
||||||
|
unrecognizedImageFaceIDs = append(unrecognizedImageFaceIDs, imageFaceID)
|
||||||
|
} else {
|
||||||
|
newFaceGroupIDs = append(newFaceGroupIDs, faceGroupID)
|
||||||
|
newDescriptors = append(newDescriptors, descriptor)
|
||||||
|
newImageFaceIDs = append(newImageFaceIDs, imageFaceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fd.faceGroupIDs = newFaceGroupIDs
|
||||||
|
fd.faceDescriptors = newDescriptors
|
||||||
|
fd.imageFaceIDs = newImageFaceIDs
|
||||||
|
|
||||||
|
updatedImageFaces := make([]*models.ImageFace, 0)
|
||||||
|
|
||||||
|
for i := range unrecognizedDescriptors {
|
||||||
|
descriptor := unrecognizedDescriptors[i]
|
||||||
|
faceGroupID := unrecognizedFaceGroupIDs[i]
|
||||||
|
imageFaceID := unrecognizedImageFaceIDs[i]
|
||||||
|
|
||||||
|
match := fd.classifyDescriptor(descriptor)
|
||||||
|
|
||||||
|
if match < 0 {
|
||||||
|
// still no match, we can readd it to the list
|
||||||
|
fd.faceGroupIDs = append(fd.faceGroupIDs, faceGroupID)
|
||||||
|
fd.faceDescriptors = append(fd.faceDescriptors, descriptor)
|
||||||
|
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFaceID)
|
||||||
|
} else {
|
||||||
|
// found new match, update the database
|
||||||
|
var imageFace models.ImageFace
|
||||||
|
if err := tx.Model(&models.ImageFace{}).First(imageFace, imageFaceID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&imageFace).Update("face_group_id", int(faceGroupID)).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedImageFaces = append(updatedImageFaces, &imageFace)
|
||||||
|
|
||||||
|
fd.faceGroupIDs = append(fd.faceGroupIDs, match)
|
||||||
|
fd.faceDescriptors = append(fd.faceDescriptors, descriptor)
|
||||||
|
fd.imageFaceIDs = append(fd.imageFaceIDs, imageFaceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedImageFaces, nil
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
package image_helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PhotoDimensions struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPhotoDimensions(imagePath string) (*PhotoDimensions, error) {
|
||||||
|
photoFile, err := os.Open(imagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer photoFile.Close()
|
||||||
|
|
||||||
|
config, _, err := image.DecodeConfig(photoFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PhotoDimensions{
|
||||||
|
Width: config.Width,
|
||||||
|
Height: config.Height,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PhotoDimensionsFromRect(rect image.Rectangle) PhotoDimensions {
|
||||||
|
return PhotoDimensions{
|
||||||
|
Width: rect.Bounds().Max.X,
|
||||||
|
Height: rect.Bounds().Max.Y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dimensions *PhotoDimensions) ThumbnailScale() PhotoDimensions {
|
||||||
|
aspect := float64(dimensions.Width) / float64(dimensions.Height)
|
||||||
|
|
||||||
|
var width, height int
|
||||||
|
|
||||||
|
if aspect > 1 {
|
||||||
|
width = 1024
|
||||||
|
height = int(1024 / aspect)
|
||||||
|
} else {
|
||||||
|
width = int(1024 * aspect)
|
||||||
|
height = 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
return PhotoDimensions{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
}
|
||||||
|
}
|
|
@ -150,6 +150,7 @@ var fileExtensions = map[string]MediaType{
|
||||||
".arw": TypeARW,
|
".arw": TypeARW,
|
||||||
".sr2": TypeSR2,
|
".sr2": TypeSR2,
|
||||||
".srf": TypeSRF,
|
".srf": TypeSRF,
|
||||||
|
".srw": TypeSRW,
|
||||||
".cr2": TypeCR2,
|
".cr2": TypeCR2,
|
||||||
".crw": TypeCRW,
|
".crw": TypeCRW,
|
||||||
".erf": TypeERF,
|
".erf": TypeERF,
|
||||||
|
@ -159,15 +160,22 @@ var fileExtensions = map[string]MediaType{
|
||||||
".mrw": TypeMRW,
|
".mrw": TypeMRW,
|
||||||
".nef": TypeNEF,
|
".nef": TypeNEF,
|
||||||
".nrw": TypeNRW,
|
".nrw": TypeNRW,
|
||||||
|
".mdc": TypeMDC,
|
||||||
|
".mef": TypeMEF,
|
||||||
".orf": TypeORF,
|
".orf": TypeORF,
|
||||||
".pef": TypePEF,
|
".pef": TypePEF,
|
||||||
".raf": TypeRAF,
|
".raf": TypeRAF,
|
||||||
".raw": TypeRAW,
|
".raw": TypeRAW,
|
||||||
|
".rw2": TypeRW2,
|
||||||
".dcs": TypeDCS,
|
".dcs": TypeDCS,
|
||||||
".drf": TypeDRF,
|
".drf": TypeDRF,
|
||||||
".gpr": TypeGPR,
|
".gpr": TypeGPR,
|
||||||
".3fr": Type3FR,
|
".3fr": Type3FR,
|
||||||
".fff": TypeFFF,
|
".fff": TypeFFF,
|
||||||
|
".cap": TypeCap,
|
||||||
|
".iiq": TypeIIQ,
|
||||||
|
".mos": TypeMOS,
|
||||||
|
".rwl": TypeRWL,
|
||||||
|
|
||||||
// Video formats
|
// Video formats
|
||||||
".mp4": TypeMP4,
|
".mp4": TypeMP4,
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"github.com/photoview/photoview/api/scanner/image_helpers"
|
||||||
"github.com/photoview/photoview/api/utils"
|
"github.com/photoview/photoview/api/utils"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
@ -110,7 +111,7 @@ func processPhoto(tx *gorm.DB, imageData *EncodeMediaData, photoCachePath *strin
|
||||||
return false, errors.Wrap(err, "error processing photo highres")
|
return false, errors.Wrap(err, "error processing photo highres")
|
||||||
}
|
}
|
||||||
|
|
||||||
var photoDimensions *PhotoDimensions
|
var photoDimensions *image_helpers.PhotoDimensions
|
||||||
var baseImagePath string = photo.Path
|
var baseImagePath string = photo.Path
|
||||||
|
|
||||||
mediaType, err := getMediaType(photo.Path)
|
mediaType, err := getMediaType(photo.Path)
|
||||||
|
@ -161,7 +162,7 @@ func processPhoto(tx *gorm.DB, imageData *EncodeMediaData, photoCachePath *strin
|
||||||
|
|
||||||
// Make sure photo dimensions is set
|
// Make sure photo dimensions is set
|
||||||
if photoDimensions == nil {
|
if photoDimensions == nil {
|
||||||
photoDimensions, err = GetPhotoDimensions(baseImagePath)
|
photoDimensions, err = image_helpers.GetPhotoDimensions(baseImagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
@ -217,14 +218,14 @@ func processPhoto(tx *gorm.DB, imageData *EncodeMediaData, photoCachePath *strin
|
||||||
func makeMediaCacheDir(media *models.Media) (*string, error) {
|
func makeMediaCacheDir(media *models.Media) (*string, error) {
|
||||||
|
|
||||||
// Make root cache dir if not exists
|
// Make root cache dir if not exists
|
||||||
if _, err := os.Stat(MediaCachePath()); os.IsNotExist(err) {
|
if _, err := os.Stat(utils.MediaCachePath()); os.IsNotExist(err) {
|
||||||
if err := os.Mkdir(MediaCachePath(), os.ModePerm); err != nil {
|
if err := os.Mkdir(utils.MediaCachePath(), os.ModePerm); err != nil {
|
||||||
return nil, errors.Wrap(err, "could not make root image cache directory")
|
return nil, errors.Wrap(err, "could not make root image cache directory")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make album cache dir if not exists
|
// Make album cache dir if not exists
|
||||||
albumCachePath := path.Join(MediaCachePath(), strconv.Itoa(int(media.AlbumID)))
|
albumCachePath := path.Join(utils.MediaCachePath(), strconv.Itoa(int(media.AlbumID)))
|
||||||
if _, err := os.Stat(albumCachePath); os.IsNotExist(err) {
|
if _, err := os.Stat(albumCachePath); os.IsNotExist(err) {
|
||||||
if err := os.Mkdir(albumCachePath, os.ModePerm); err != nil {
|
if err := os.Mkdir(albumCachePath, os.ModePerm); err != nil {
|
||||||
return nil, errors.Wrap(err, "could not make album image cache directory")
|
return nil, errors.Wrap(err, "could not make album image cache directory")
|
||||||
|
@ -242,7 +243,7 @@ func makeMediaCacheDir(media *models.Media) (*string, error) {
|
||||||
return &photoCachePath, nil
|
return &photoCachePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *EncodeMediaData, photoDimensions *PhotoDimensions) error {
|
func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *EncodeMediaData, photoDimensions *image_helpers.PhotoDimensions) error {
|
||||||
originalImageName := generateUniqueMediaName(photo.Path)
|
originalImageName := generateUniqueMediaName(photo.Path)
|
||||||
|
|
||||||
contentType, err := imageData.ContentType()
|
contentType, err := imageData.ContentType()
|
||||||
|
@ -256,7 +257,7 @@ func saveOriginalPhotoToDB(tx *gorm.DB, photo *models.Media, imageData *EncodeMe
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaURL := models.MediaURL{
|
mediaURL := models.MediaURL{
|
||||||
Media: *photo,
|
Media: photo,
|
||||||
MediaName: originalImageName,
|
MediaName: originalImageName,
|
||||||
Width: photoDimensions.Width,
|
Width: photoDimensions.Width,
|
||||||
Height: photoDimensions.Height,
|
Height: photoDimensions.Height,
|
||||||
|
@ -279,7 +280,7 @@ func generateSaveHighResJPEG(tx *gorm.DB, media *models.Media, imageData *Encode
|
||||||
return nil, errors.Wrap(err, "creating high-res cached image")
|
return nil, errors.Wrap(err, "creating high-res cached image")
|
||||||
}
|
}
|
||||||
|
|
||||||
photoDimensions, err := GetPhotoDimensions(imagePath)
|
photoDimensions, err := image_helpers.GetPhotoDimensions(imagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
|
"github.com/photoview/photoview/api/scanner/image_helpers"
|
||||||
"github.com/photoview/photoview/api/utils"
|
"github.com/photoview/photoview/api/utils"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gopkg.in/vansante/go-ffprobe.v2"
|
"gopkg.in/vansante/go-ffprobe.v2"
|
||||||
|
@ -131,7 +132,7 @@ func processVideo(tx *gorm.DB, mediaData *EncodeMediaData, videoCachePath *strin
|
||||||
return false, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
|
return false, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbDimensions, err := GetPhotoDimensions(thumbImagePath)
|
thumbDimensions, err := image_helpers.GetPhotoDimensions(thumbImagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "get dimensions of video thumbnail image")
|
return false, errors.Wrap(err, "get dimensions of video thumbnail image")
|
||||||
}
|
}
|
||||||
|
@ -167,7 +168,7 @@ func processVideo(tx *gorm.DB, mediaData *EncodeMediaData, videoCachePath *strin
|
||||||
return false, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
|
return false, errors.Wrapf(err, "failed to generate thumbnail for video (%s)", video.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbDimensions, err := GetPhotoDimensions(thumbImagePath)
|
thumbDimensions, err := image_helpers.GetPhotoDimensions(thumbImagePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errors.Wrap(err, "get dimensions of video thumbnail image")
|
return false, errors.Wrap(err, "get dimensions of video thumbnail image")
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/photoview/photoview/api/graphql/models"
|
"github.com/photoview/photoview/api/graphql/models"
|
||||||
"github.com/photoview/photoview/api/graphql/notification"
|
"github.com/photoview/photoview/api/graphql/notification"
|
||||||
|
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||||
"github.com/photoview/photoview/api/utils"
|
"github.com/photoview/photoview/api/utils"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
@ -54,7 +55,7 @@ func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.DB) {
|
||||||
notifyThrottle.Trigger(nil)
|
notifyThrottle.Trigger(nil)
|
||||||
|
|
||||||
// Scan for photos
|
// Scan for photos
|
||||||
albumPhotos, err := findMediaForAlbum(album, cache, db, func(photo *models.Media, newPhoto bool) {
|
albumMedia, err := findMediaForAlbum(album, cache, db, func(photo *models.Media, newPhoto bool) {
|
||||||
if newPhoto {
|
if newPhoto {
|
||||||
notifyThrottle.Trigger(func() {
|
notifyThrottle.Trigger(func() {
|
||||||
notification.BroadcastNotification(&models.Notification{
|
notification.BroadcastNotification(&models.Notification{
|
||||||
|
@ -71,25 +72,33 @@ func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.DB) {
|
||||||
}
|
}
|
||||||
|
|
||||||
album_has_changes := false
|
album_has_changes := false
|
||||||
for count, photo := range albumPhotos {
|
for count, media := range albumMedia {
|
||||||
// tx, err := db.Begin()
|
// tx, err := db.Begin()
|
||||||
|
|
||||||
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
transactionError := db.Transaction(func(tx *gorm.DB) error {
|
||||||
processing_was_needed, err := ProcessMedia(tx, photo)
|
processing_was_needed, err := ProcessMedia(tx, media)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "failed to process photo (%s)", photo.Path)
|
return errors.Wrapf(err, "failed to process photo (%s)", media.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if processing_was_needed {
|
if processing_was_needed {
|
||||||
album_has_changes = true
|
album_has_changes = true
|
||||||
progress := float64(count) / float64(len(albumPhotos)) * 100.0
|
progress := float64(count) / float64(len(albumMedia)) * 100.0
|
||||||
notification.BroadcastNotification(&models.Notification{
|
notification.BroadcastNotification(&models.Notification{
|
||||||
Key: album_notify_key,
|
Key: album_notify_key,
|
||||||
Type: models.NotificationTypeProgress,
|
Type: models.NotificationTypeProgress,
|
||||||
Header: fmt.Sprintf("Processing media for album '%s'", album.Title),
|
Header: fmt.Sprintf("Processing media for album '%s'", album.Title),
|
||||||
Content: fmt.Sprintf("Processed media at %s", photo.Path),
|
Content: fmt.Sprintf("Processed media at %s", media.Path),
|
||||||
Progress: &progress,
|
Progress: &progress,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if media.Type == models.MediaTypePhoto {
|
||||||
|
go func() {
|
||||||
|
if err := face_detection.GlobalFaceDetector.DetectFaces(media); err != nil {
|
||||||
|
ScannerError("Error detecting faces in image (%s): %s", media.Path, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -100,7 +109,7 @@ func scanAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.DB) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanup_errors := CleanupMedia(db, album.ID, albumPhotos)
|
cleanup_errors := CleanupMedia(db, album.ID, albumMedia)
|
||||||
for _, err := range cleanup_errors {
|
for _, err := range cleanup_errors {
|
||||||
ScannerError("Failed to delete old media: %s", err)
|
ScannerError("Failed to delete old media: %s", err)
|
||||||
}
|
}
|
||||||
|
@ -138,14 +147,14 @@ func findMediaForAlbum(album *models.Album, cache *AlbumScannerCache, db *gorm.D
|
||||||
}
|
}
|
||||||
|
|
||||||
err := db.Transaction(func(tx *gorm.DB) error {
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
photo, isNewPhoto, err := ScanMedia(tx, photoPath, album.ID, cache)
|
media, isNewMedia, err := ScanMedia(tx, photoPath, album.ID, cache)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "Scanning media error (%s)", photoPath)
|
return errors.Wrapf(err, "Scanning media error (%s)", photoPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
onScanPhoto(photo, isNewPhoto)
|
onScanPhoto(media, isNewMedia)
|
||||||
|
|
||||||
albumPhotos = append(albumPhotos, photo)
|
albumPhotos = append(albumPhotos, media)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
|
@ -220,13 +220,3 @@ func ScannerError(format string, args ...interface{}) {
|
||||||
Negative: true,
|
Negative: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaCachePath returns the path for where the media cache is located on the file system
|
|
||||||
func MediaCachePath() string {
|
|
||||||
photoCache := utils.EnvMediaCachePath.GetValue()
|
|
||||||
if photoCache == "" {
|
|
||||||
photoCache = "./media_cache"
|
|
||||||
}
|
|
||||||
|
|
||||||
return photoCache
|
|
||||||
}
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/handlers"
|
"github.com/gorilla/handlers"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
@ -15,6 +16,7 @@ import (
|
||||||
"github.com/photoview/photoview/api/graphql/dataloader"
|
"github.com/photoview/photoview/api/graphql/dataloader"
|
||||||
"github.com/photoview/photoview/api/routes"
|
"github.com/photoview/photoview/api/routes"
|
||||||
"github.com/photoview/photoview/api/scanner"
|
"github.com/photoview/photoview/api/scanner"
|
||||||
|
"github.com/photoview/photoview/api/scanner/face_detection"
|
||||||
"github.com/photoview/photoview/api/server"
|
"github.com/photoview/photoview/api/server"
|
||||||
"github.com/photoview/photoview/api/utils"
|
"github.com/photoview/photoview/api/utils"
|
||||||
|
|
||||||
|
@ -51,6 +53,10 @@ func main() {
|
||||||
|
|
||||||
scanner.InitializeExecutableWorkers()
|
scanner.InitializeExecutableWorkers()
|
||||||
|
|
||||||
|
if err := face_detection.InitializeFaceDetector(db); err != nil {
|
||||||
|
log.Panicf("Could not initialize face detector: %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
rootRouter := mux.NewRouter()
|
rootRouter := mux.NewRouter()
|
||||||
|
|
||||||
rootRouter.Use(dataloader.Middleware(db))
|
rootRouter.Use(dataloader.Middleware(db))
|
||||||
|
@ -83,6 +89,7 @@ func main() {
|
||||||
handler.GraphQL(photoview_graphql.NewExecutableSchema(graphqlConfig),
|
handler.GraphQL(photoview_graphql.NewExecutableSchema(graphqlConfig),
|
||||||
handler.IntrospectionEnabled(devMode),
|
handler.IntrospectionEnabled(devMode),
|
||||||
handler.WebsocketUpgrader(server.WebsocketUpgrader(devMode)),
|
handler.WebsocketUpgrader(server.WebsocketUpgrader(devMode)),
|
||||||
|
handler.WebsocketKeepAliveDuration(time.Second*10),
|
||||||
handler.WebsocketInitFunc(auth.AuthWebsocketInit(db)),
|
handler.WebsocketInitFunc(auth.AuthWebsocketInit(db)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -41,3 +41,13 @@ func HandleError(message string, err error) PhotoviewError {
|
||||||
original: err,
|
original: err,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MediaCachePath returns the path for where the media cache is located on the file system
|
||||||
|
func MediaCachePath() string {
|
||||||
|
photoCache := EnvMediaCachePath.GetValue()
|
||||||
|
if photoCache == "" {
|
||||||
|
photoCache = "./media_cache"
|
||||||
|
}
|
||||||
|
|
||||||
|
return photoCache
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,107 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Script to configure environment variables for Go compiler
|
||||||
|
# to allow cross compilation
|
||||||
|
|
||||||
|
: ${TARGETPLATFORM=}
|
||||||
|
: ${TARGETOS=}
|
||||||
|
: ${TARGETARCH=}
|
||||||
|
: ${TARGETVARIANT=}
|
||||||
|
: ${CGO_ENABLED=}
|
||||||
|
: ${GOARCH=}
|
||||||
|
: ${GOOS=}
|
||||||
|
: ${GOARM=}
|
||||||
|
: ${GOBIN=}
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ ! -z "$TARGETPLATFORM" ]; then
|
||||||
|
os="$(echo $TARGETPLATFORM | cut -d"/" -f1)"
|
||||||
|
arch="$(echo $TARGETPLATFORM | cut -d"/" -f2)"
|
||||||
|
if [ ! -z "$os" ] && [ ! -z "$arch" ]; then
|
||||||
|
export GOOS="$os"
|
||||||
|
export GOARCH="$arch"
|
||||||
|
if [ "$arch" = "arm" ]; then
|
||||||
|
case "$(echo $TARGETPLATFORM | cut -d"/" -f3)" in
|
||||||
|
"v5")
|
||||||
|
export GOARM="5"
|
||||||
|
;;
|
||||||
|
"v6")
|
||||||
|
export GOARM="6"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
export GOARM="7"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -z "$TARGETOS" ]; then
|
||||||
|
export GOOS="$TARGETOS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -z "$TARGETARCH" ]; then
|
||||||
|
export GOARCH="$TARGETARCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$TARGETARCH" = "arm" ]; then
|
||||||
|
if [ ! -z "$TARGETVARIANT" ]; then
|
||||||
|
case "$TARGETVARIANT" in
|
||||||
|
"v5")
|
||||||
|
export GOARM="5"
|
||||||
|
;;
|
||||||
|
"v6")
|
||||||
|
export GOARM="6"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
export GOARM="7"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
export GOARM="7"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$CGO_ENABLED" = "1" ]; then
|
||||||
|
case "$GOARCH" in
|
||||||
|
"amd64")
|
||||||
|
export CC="x86_64-linux-gnu-gcc"
|
||||||
|
export CXX="x86_64-linux-gnu-g++"
|
||||||
|
;;
|
||||||
|
"ppc64le")
|
||||||
|
export CC="powerpc64le-linux-gnu-gcc"
|
||||||
|
export CXX="powerpc64le-linux-gnu-g++"
|
||||||
|
;;
|
||||||
|
"s390x")
|
||||||
|
export CC="s390x-linux-gnu-gcc"
|
||||||
|
export CXX="s390x-linux-gnu-g++"
|
||||||
|
;;
|
||||||
|
"arm64")
|
||||||
|
export CC="aarch64-linux-gnu-gcc"
|
||||||
|
export CXX="aarch64-linux-gnu-g++"
|
||||||
|
;;
|
||||||
|
"arm")
|
||||||
|
case "$GOARM" in
|
||||||
|
"5")
|
||||||
|
export CC="arm-linux-gnueabi-gcc"
|
||||||
|
export CXX="arm-linux-gnueabi-g++"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
export CC="arm-linux-gnueabihf-gcc"
|
||||||
|
export CXX="arm-linux-gnueabihf-g++"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$GOOS" = "wasi" ]; then
|
||||||
|
export GOOS="js"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$GOBIN" ] && [ -n "$GOPATH" ] && [ -n "$GOARCH" ] && [ -n "$GOOS" ]; then
|
||||||
|
export PATH=${GOPATH}/bin/${GOOS}_${GOARCH}:${PATH}
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec /usr/local/go/bin/go "$@"
|
|
@ -104,19 +104,23 @@ export const SideMenu = () => {
|
||||||
return (
|
return (
|
||||||
<SideMenuContainer>
|
<SideMenuContainer>
|
||||||
<SideButton to="/photos" exact>
|
<SideButton to="/photos" exact>
|
||||||
<Icon name="image outline" />
|
<Icon name="image" />
|
||||||
<SideButtonLabel>Photos</SideButtonLabel>
|
<SideButtonLabel>Photos</SideButtonLabel>
|
||||||
</SideButton>
|
</SideButton>
|
||||||
<SideButton to="/albums" exact>
|
<SideButton to="/albums" exact>
|
||||||
<Icon name="images outline" />
|
<Icon name="images" />
|
||||||
<SideButtonLabel>Albums</SideButtonLabel>
|
<SideButtonLabel>Albums</SideButtonLabel>
|
||||||
</SideButton>
|
</SideButton>
|
||||||
{mapboxEnabled ? (
|
{mapboxEnabled ? (
|
||||||
<SideButton to="/places" exact>
|
<SideButton to="/places" exact>
|
||||||
<Icon name="map outline" />
|
<Icon name="map" />
|
||||||
<SideButtonLabel>Places</SideButtonLabel>
|
<SideButtonLabel>Places</SideButtonLabel>
|
||||||
</SideButton>
|
</SideButton>
|
||||||
) : null}
|
) : null}
|
||||||
|
<SideButton to="/people" exact>
|
||||||
|
<Icon name="user" />
|
||||||
|
<SideButtonLabel>People</SideButtonLabel>
|
||||||
|
</SideButton>
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<SideButton to="/settings" exact>
|
<SideButton to="/settings" exact>
|
||||||
<Icon name="settings" />
|
<Icon name="settings" />
|
||||||
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
import React from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import { ProtectedImage } from '../../components/photoGallery/ProtectedMedia'
|
||||||
|
|
||||||
|
const FaceImage = styled(ProtectedImage)`
|
||||||
|
position: absolute;
|
||||||
|
transform-origin: ${({ $origin }) =>
|
||||||
|
`${$origin.x * 100}% ${$origin.y * 100}%`};
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
transition: transform 250ms ease-out;
|
||||||
|
`
|
||||||
|
|
||||||
|
const FaceImagePortrait = styled(FaceImage)`
|
||||||
|
width: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%)
|
||||||
|
${({ $origin, $scale }) =>
|
||||||
|
`translate(${(0.5 - $origin.x) * 100}%, ${
|
||||||
|
(0.5 - $origin.y) * 100
|
||||||
|
}%) scale(${Math.max($scale * 0.8, 1)})`};
|
||||||
|
|
||||||
|
${({ $selectable, $origin, $scale }) =>
|
||||||
|
$selectable
|
||||||
|
? `
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-50%) translate(${(0.5 - $origin.x) * 100}%, ${
|
||||||
|
(0.5 - $origin.y) * 100
|
||||||
|
}%) scale(${Math.max($scale * 0.85, 1)})
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
`
|
||||||
|
|
||||||
|
const FaceImageLandscape = styled(FaceImage)`
|
||||||
|
height: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%)
|
||||||
|
${({ $origin, $scale }) =>
|
||||||
|
`translate(${(0.5 - $origin.x) * 100}%, ${
|
||||||
|
(0.5 - $origin.y) * 100
|
||||||
|
}%) scale(${Math.max($scale * 0.8, 1)})`};
|
||||||
|
|
||||||
|
${({ $selectable, $origin, $scale }) =>
|
||||||
|
$selectable
|
||||||
|
? `
|
||||||
|
&:hover {
|
||||||
|
transform: translateX(-50%) translate(${(0.5 - $origin.x) * 100}%, ${
|
||||||
|
(0.5 - $origin.y) * 100
|
||||||
|
}%) scale(${Math.max($scale * 0.85, 1)})
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
`
|
||||||
|
|
||||||
|
const CircleImageWrapper = styled.div`
|
||||||
|
background-color: #eee;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: ${({ size }) => size};
|
||||||
|
height: ${({ size }) => size};
|
||||||
|
object-fit: fill;
|
||||||
|
overflow: hidden;
|
||||||
|
`
|
||||||
|
|
||||||
|
const FaceCircleImage = ({ imageFace, selectable, size = '150px' }) => {
|
||||||
|
if (!imageFace) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = imageFace.rectangle
|
||||||
|
|
||||||
|
let scale = Math.min(1 / (rect.maxX - rect.minX), 1 / (rect.maxY - rect.minY))
|
||||||
|
|
||||||
|
let origin = {
|
||||||
|
x: (rect.minX + rect.maxX) / 2,
|
||||||
|
y: (rect.minY + rect.maxY) / 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpecificFaceImage =
|
||||||
|
imageFace.media.thumbnail.width > imageFace.media.thumbnail.height
|
||||||
|
? FaceImageLandscape
|
||||||
|
: FaceImagePortrait
|
||||||
|
return (
|
||||||
|
<CircleImageWrapper size={size}>
|
||||||
|
<SpecificFaceImage
|
||||||
|
$selectable={selectable}
|
||||||
|
$scale={scale}
|
||||||
|
$origin={origin}
|
||||||
|
src={imageFace.media.thumbnail.url}
|
||||||
|
/>
|
||||||
|
</CircleImageWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FaceCircleImage.propTypes = {
|
||||||
|
imageFace: PropTypes.object,
|
||||||
|
selectable: PropTypes.bool,
|
||||||
|
size: PropTypes.string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FaceCircleImage
|
|
@ -0,0 +1,253 @@
|
||||||
|
import React, { createRef, useEffect, useState } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||||
|
import Layout from '../../Layout'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import SingleFaceGroup from './SingleFaceGroup/SingleFaceGroup'
|
||||||
|
import { Button, Icon, Input } from 'semantic-ui-react'
|
||||||
|
import FaceCircleImage from './FaceCircleImage'
|
||||||
|
|
||||||
|
export const MY_FACES_QUERY = gql`
|
||||||
|
query myFaces {
|
||||||
|
myFaceGroups {
|
||||||
|
id
|
||||||
|
label
|
||||||
|
imageFaces {
|
||||||
|
id
|
||||||
|
rectangle {
|
||||||
|
minX
|
||||||
|
maxX
|
||||||
|
minY
|
||||||
|
maxY
|
||||||
|
}
|
||||||
|
media {
|
||||||
|
id
|
||||||
|
type
|
||||||
|
title
|
||||||
|
thumbnail {
|
||||||
|
url
|
||||||
|
width
|
||||||
|
height
|
||||||
|
}
|
||||||
|
highRes {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
favorite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SET_GROUP_LABEL_MUTATION = gql`
|
||||||
|
mutation($groupID: ID!, $label: String) {
|
||||||
|
setFaceGroupLabel(faceGroupID: $groupID, label: $label) {
|
||||||
|
id
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const RECOGNIZE_UNLABELED_FACES_MUTATION = gql`
|
||||||
|
mutation recognizeUnlabeledFaces {
|
||||||
|
recognizeUnlabeledFaces {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const FaceDetailsButton = styled.button`
|
||||||
|
color: ${({ labeled }) => (labeled ? 'black' : '#aaa')};
|
||||||
|
margin: 12px auto 24px;
|
||||||
|
text-align: center;
|
||||||
|
display: block;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
color: #2683ca;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const FaceLabel = styled.span``
|
||||||
|
|
||||||
|
const FaceDetails = ({ group }) => {
|
||||||
|
const [editLabel, setEditLabel] = useState(false)
|
||||||
|
const [inputValue, setInputValue] = useState(group.label ?? '')
|
||||||
|
const inputRef = createRef()
|
||||||
|
|
||||||
|
const [setGroupLabel, { loading }] = useMutation(SET_GROUP_LABEL_MUTATION, {
|
||||||
|
variables: {
|
||||||
|
groupID: group.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetLabel = () => {
|
||||||
|
setInputValue(group.label ?? '')
|
||||||
|
setEditLabel(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [inputRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) {
|
||||||
|
resetLabel()
|
||||||
|
}
|
||||||
|
}, [loading])
|
||||||
|
|
||||||
|
const onKeyUp = e => {
|
||||||
|
if (e.key == 'Escape') {
|
||||||
|
resetLabel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key == 'Enter') {
|
||||||
|
setGroupLabel({
|
||||||
|
variables: {
|
||||||
|
label: e.target.value == '' ? null : e.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let label
|
||||||
|
if (!editLabel) {
|
||||||
|
label = (
|
||||||
|
<FaceDetailsButton
|
||||||
|
labeled={!!group.label}
|
||||||
|
onClick={() => setEditLabel(true)}
|
||||||
|
>
|
||||||
|
<FaceImagesCount>{group.imageFaces.length}</FaceImagesCount>
|
||||||
|
<FaceLabel>{group.label ?? 'Unlabeled'}</FaceLabel>
|
||||||
|
<EditIcon name="pencil" />
|
||||||
|
</FaceDetailsButton>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
label = (
|
||||||
|
<FaceDetailsButton labeled={!!group.label}>
|
||||||
|
<Input
|
||||||
|
loading={loading}
|
||||||
|
ref={inputRef}
|
||||||
|
size="mini"
|
||||||
|
placeholder="Label"
|
||||||
|
icon="arrow right"
|
||||||
|
value={inputValue}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onChange={e => setInputValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
resetLabel()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FaceDetailsButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
FaceDetails.propTypes = {
|
||||||
|
group: PropTypes.object.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FaceImagesCount = styled.span`
|
||||||
|
background-color: #eee;
|
||||||
|
color: #222;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin-right: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const EditIcon = styled(Icon)`
|
||||||
|
margin-left: 6px !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
|
||||||
|
transition: opacity 100ms;
|
||||||
|
|
||||||
|
${FaceDetailsButton}:hover &, ${FaceDetailsButton}:focus-visible & {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const FaceGroup = ({ group }) => {
|
||||||
|
const previewFace = group.imageFaces[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ margin: '12px' }}>
|
||||||
|
<Link to={`/people/${group.id}`}>
|
||||||
|
<FaceCircleImage imageFace={previewFace} selectable />
|
||||||
|
</Link>
|
||||||
|
<FaceDetails group={group} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FaceGroup.propTypes = {
|
||||||
|
group: PropTypes.any,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FaceGroupsWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`
|
||||||
|
|
||||||
|
const PeoplePage = ({ match }) => {
|
||||||
|
const { data, error } = useQuery(MY_FACES_QUERY)
|
||||||
|
|
||||||
|
const [
|
||||||
|
recognizeUnlabeled,
|
||||||
|
{ loading: recognizeUnlabeledLoading },
|
||||||
|
] = useMutation(RECOGNIZE_UNLABELED_FACES_MUTATION)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
const faceGroup = match.params.person
|
||||||
|
if (faceGroup) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<SingleFaceGroup
|
||||||
|
faceGroup={data?.myFaceGroups?.find(x => x.id == faceGroup)}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let faces = null
|
||||||
|
if (data) {
|
||||||
|
faces = data.myFaceGroups.map(faceGroup => (
|
||||||
|
<FaceGroup key={faceGroup.id} group={faceGroup} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title={'People'}>
|
||||||
|
<FaceGroupsWrapper>{faces}</FaceGroupsWrapper>
|
||||||
|
<Button
|
||||||
|
loading={recognizeUnlabeledLoading}
|
||||||
|
disabled={recognizeUnlabeledLoading}
|
||||||
|
onClick={() => {
|
||||||
|
recognizeUnlabeled()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="sync" />
|
||||||
|
Recognize unlabeled faces
|
||||||
|
</Button>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PeoplePage.propTypes = {
|
||||||
|
match: PropTypes.object.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PeoplePage
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { gql, useMutation } from '@apollo/client'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useHistory } from 'react-router-dom'
|
||||||
|
import { Button, Modal } from 'semantic-ui-react'
|
||||||
|
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||||
|
import SelectImageFacesTable from './SelectImageFacesTable'
|
||||||
|
|
||||||
|
const DETACH_IMAGE_FACES_MUTATION = gql`
|
||||||
|
mutation detachImageFaces($faceIDs: [ID!]!) {
|
||||||
|
detachImageFaces(imageFaceIDs: $faceIDs) {
|
||||||
|
id
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const DetachImageFacesModal = ({ open, setOpen, faceGroup }) => {
|
||||||
|
const [selectedImageFaces, setSelectedImageFaces] = useState([])
|
||||||
|
let history = useHistory()
|
||||||
|
|
||||||
|
const [detachImageFacesMutation] = useMutation(DETACH_IMAGE_FACES_MUTATION, {
|
||||||
|
variables: {},
|
||||||
|
refetchQueries: [
|
||||||
|
{
|
||||||
|
query: MY_FACES_QUERY,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedImageFaces([])
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (open == false) return null
|
||||||
|
|
||||||
|
const detachImageFaces = () => {
|
||||||
|
const faceIDs = selectedImageFaces.map(face => face.id)
|
||||||
|
|
||||||
|
detachImageFacesMutation({
|
||||||
|
variables: {
|
||||||
|
faceIDs,
|
||||||
|
},
|
||||||
|
}).then(({ data }) => {
|
||||||
|
setOpen(false)
|
||||||
|
history.push(`/people/${data.detachImageFaces.id}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFaces = faceGroup?.imageFaces ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onOpen={() => setOpen(true)}
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
|
<Modal.Header>Detach Image Faces</Modal.Header>
|
||||||
|
<Modal.Content scrolling>
|
||||||
|
<Modal.Description>
|
||||||
|
<p>
|
||||||
|
Detach selected images of this face group and move them to a new
|
||||||
|
face group
|
||||||
|
</p>
|
||||||
|
<SelectImageFacesTable
|
||||||
|
imageFaces={imageFaces}
|
||||||
|
selectedImageFaces={selectedImageFaces}
|
||||||
|
setSelectedImageFaces={setSelectedImageFaces}
|
||||||
|
title="Select images to detach"
|
||||||
|
/>
|
||||||
|
</Modal.Description>
|
||||||
|
</Modal.Content>
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={selectedImageFaces.length == 0}
|
||||||
|
content="Detach image faces"
|
||||||
|
labelPosition="right"
|
||||||
|
icon="checkmark"
|
||||||
|
onClick={() => detachImageFaces()}
|
||||||
|
positive
|
||||||
|
/>
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DetachImageFacesModal.propTypes = {
|
||||||
|
open: PropTypes.bool.isRequired,
|
||||||
|
setOpen: PropTypes.func.isRequired,
|
||||||
|
faceGroup: PropTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetachImageFacesModal
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { useMutation } from '@apollo/client'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, { useState, useEffect, createRef } from 'react'
|
||||||
|
import { Dropdown, Input } from 'semantic-ui-react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import { SET_GROUP_LABEL_MUTATION } from '../PeoplePage'
|
||||||
|
import DetachImageFacesModal from './DetachImageFacesModal'
|
||||||
|
import MergeFaceGroupsModal from './MergeFaceGroupsModal'
|
||||||
|
import MoveImageFacesModal from './MoveImageFacesModal'
|
||||||
|
|
||||||
|
const TitleWrapper = styled.div`
|
||||||
|
min-height: 3.5em;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TitleLabel = styled.h1`
|
||||||
|
display: inline-block;
|
||||||
|
color: ${({ labeled }) => (labeled ? 'black' : '#888')};
|
||||||
|
margin-right: 12px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const TitleDropdown = styled(Dropdown)`
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-top: -10px;
|
||||||
|
color: #888;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1e70bf;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const FaceGroupTitle = ({ faceGroup }) => {
|
||||||
|
const [editLabel, setEditLabel] = useState(false)
|
||||||
|
const [inputValue, setInputValue] = useState(faceGroup?.label ?? '')
|
||||||
|
const inputRef = createRef()
|
||||||
|
const [mergeModalOpen, setMergeModalOpen] = useState(false)
|
||||||
|
const [moveModalOpen, setMoveModalOpen] = useState(false)
|
||||||
|
const [detachModalOpen, setDetachModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const [setGroupLabel, { loading: setLabelLoading }] = useMutation(
|
||||||
|
SET_GROUP_LABEL_MUTATION,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
groupID: faceGroup?.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetLabel = () => {
|
||||||
|
setInputValue(faceGroup?.label ?? '')
|
||||||
|
setEditLabel(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [inputRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setLabelLoading) {
|
||||||
|
resetLabel()
|
||||||
|
}
|
||||||
|
}, [setLabelLoading])
|
||||||
|
|
||||||
|
const onKeyUp = e => {
|
||||||
|
if (e.key == 'Escape') {
|
||||||
|
resetLabel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key == 'Enter') {
|
||||||
|
setGroupLabel({
|
||||||
|
variables: {
|
||||||
|
label: e.target.value == '' ? null : e.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let title
|
||||||
|
if (!editLabel) {
|
||||||
|
title = (
|
||||||
|
<TitleWrapper>
|
||||||
|
<TitleLabel labeled={!!faceGroup?.label}>
|
||||||
|
{faceGroup?.label ?? 'Unlabeled person'}
|
||||||
|
</TitleLabel>
|
||||||
|
<TitleDropdown
|
||||||
|
icon={{
|
||||||
|
name: 'settings',
|
||||||
|
size: 'large',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item
|
||||||
|
icon="pencil"
|
||||||
|
text={faceGroup?.label ? 'Change Label' : 'Add Label'}
|
||||||
|
onClick={() => setEditLabel(true)}
|
||||||
|
/>
|
||||||
|
<Dropdown.Item
|
||||||
|
icon="object group"
|
||||||
|
text="Merge Face"
|
||||||
|
onClick={() => setMergeModalOpen(true)}
|
||||||
|
/>
|
||||||
|
<Dropdown.Item
|
||||||
|
icon="object ungroup"
|
||||||
|
text="Detach Faces"
|
||||||
|
onClick={() => setDetachModalOpen(true)}
|
||||||
|
/>
|
||||||
|
<Dropdown.Item
|
||||||
|
icon="clone"
|
||||||
|
text="Move Faces"
|
||||||
|
onClick={() => setMoveModalOpen(true)}
|
||||||
|
/>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</TitleDropdown>
|
||||||
|
</TitleWrapper>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
title = (
|
||||||
|
<TitleWrapper>
|
||||||
|
<Input
|
||||||
|
loading={setLabelLoading}
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="Label"
|
||||||
|
icon="arrow right"
|
||||||
|
value={inputValue}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onChange={e => setInputValue(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
resetLabel()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TitleWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{title}
|
||||||
|
<MergeFaceGroupsModal
|
||||||
|
open={mergeModalOpen}
|
||||||
|
setOpen={setMergeModalOpen}
|
||||||
|
sourceFaceGroup={faceGroup}
|
||||||
|
/>
|
||||||
|
<MoveImageFacesModal
|
||||||
|
open={moveModalOpen}
|
||||||
|
setOpen={setMoveModalOpen}
|
||||||
|
faceGroup={faceGroup}
|
||||||
|
/>
|
||||||
|
<DetachImageFacesModal
|
||||||
|
open={detachModalOpen}
|
||||||
|
setOpen={setDetachModalOpen}
|
||||||
|
faceGroup={faceGroup}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FaceGroupTitle.propTypes = {
|
||||||
|
faceGroup: PropTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FaceGroupTitle
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useHistory } from 'react-router-dom'
|
||||||
|
import { Button, Modal } from 'semantic-ui-react'
|
||||||
|
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||||
|
import SelectFaceGroupTable from './SelectFaceGroupTable'
|
||||||
|
|
||||||
|
const COMBINE_FACES_MUTATION = gql`
|
||||||
|
mutation($destID: ID!, $srcID: ID!) {
|
||||||
|
combineFaceGroups(
|
||||||
|
destinationFaceGroupID: $destID
|
||||||
|
sourceFaceGroupID: $srcID
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const MergeFaceGroupsModal = ({ open, setOpen, sourceFaceGroup }) => {
|
||||||
|
const [selectedFaceGroup, setSelectedFaceGroup] = useState(null)
|
||||||
|
|
||||||
|
let history = useHistory()
|
||||||
|
const { data } = useQuery(MY_FACES_QUERY)
|
||||||
|
const [combineFacesMutation] = useMutation(COMBINE_FACES_MUTATION, {
|
||||||
|
variables: {
|
||||||
|
srcID: sourceFaceGroup?.id,
|
||||||
|
},
|
||||||
|
refetchQueries: [
|
||||||
|
{
|
||||||
|
query: MY_FACES_QUERY,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (open == false) return null
|
||||||
|
|
||||||
|
const filteredFaceGroups =
|
||||||
|
data?.myFaceGroups.filter(x => x.id != sourceFaceGroup?.id) ?? []
|
||||||
|
|
||||||
|
const mergeFaceGroups = () => {
|
||||||
|
combineFacesMutation({
|
||||||
|
variables: {
|
||||||
|
destID: selectedFaceGroup.id,
|
||||||
|
},
|
||||||
|
}).then(() => {
|
||||||
|
setOpen(false)
|
||||||
|
history.push(`/people/${selectedFaceGroup.id}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onOpen={() => setOpen(true)}
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
|
<Modal.Header>Merge Face Groups</Modal.Header>
|
||||||
|
<Modal.Content scrolling>
|
||||||
|
<Modal.Description>
|
||||||
|
<p>
|
||||||
|
All images within this face group will be merged into the selected
|
||||||
|
face group.
|
||||||
|
</p>
|
||||||
|
<SelectFaceGroupTable
|
||||||
|
title="Select the destination face"
|
||||||
|
faceGroups={filteredFaceGroups}
|
||||||
|
selectedFaceGroup={selectedFaceGroup}
|
||||||
|
setSelectedFaceGroup={setSelectedFaceGroup}
|
||||||
|
/>
|
||||||
|
</Modal.Description>
|
||||||
|
</Modal.Content>
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
disabled={selectedFaceGroup == null}
|
||||||
|
content="Merge"
|
||||||
|
labelPosition="right"
|
||||||
|
icon="checkmark"
|
||||||
|
onClick={() => mergeFaceGroups()}
|
||||||
|
positive
|
||||||
|
/>
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MergeFaceGroupsModal.propTypes = {
|
||||||
|
open: PropTypes.bool.isRequired,
|
||||||
|
setOpen: PropTypes.func.isRequired,
|
||||||
|
sourceFaceGroup: PropTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MergeFaceGroupsModal
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { useHistory } from 'react-router-dom'
|
||||||
|
import { Button, Modal } from 'semantic-ui-react'
|
||||||
|
import SelectFaceGroupTable from './SelectFaceGroupTable'
|
||||||
|
import SelectImageFacesTable from './SelectImageFacesTable'
|
||||||
|
import { MY_FACES_QUERY } from '../PeoplePage'
|
||||||
|
|
||||||
|
const MOVE_IMAGE_FACES_MUTATION = gql`
|
||||||
|
mutation moveImageFaces($faceIDs: [ID!]!, $destFaceGroupID: ID!) {
|
||||||
|
moveImageFaces(
|
||||||
|
imageFaceIDs: $faceIDs
|
||||||
|
destinationFaceGroupID: $destFaceGroupID
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
imageFaces {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const MoveImageFacesModal = ({ open, setOpen, faceGroup }) => {
|
||||||
|
const [selectedImageFaces, setSelectedImageFaces] = useState([])
|
||||||
|
const [selectedFaceGroup, setSelectedFaceGroup] = useState(null)
|
||||||
|
const [imagesSelected, setImagesSelected] = useState(false)
|
||||||
|
let history = useHistory()
|
||||||
|
|
||||||
|
const [moveImageFacesMutation] = useMutation(MOVE_IMAGE_FACES_MUTATION, {
|
||||||
|
variables: {},
|
||||||
|
refetchQueries: [
|
||||||
|
{
|
||||||
|
query: MY_FACES_QUERY,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const [loadFaceGroups, { data: faceGroupsData }] = useLazyQuery(
|
||||||
|
MY_FACES_QUERY
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (imagesSelected) {
|
||||||
|
loadFaceGroups()
|
||||||
|
}
|
||||||
|
}, [imagesSelected])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setImagesSelected(false)
|
||||||
|
setSelectedImageFaces([])
|
||||||
|
setSelectedFaceGroup(null)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (open == false) return null
|
||||||
|
|
||||||
|
const moveImageFaces = () => {
|
||||||
|
const faceIDs = selectedImageFaces.map(face => face.id)
|
||||||
|
|
||||||
|
moveImageFacesMutation({
|
||||||
|
variables: {
|
||||||
|
faceIDs,
|
||||||
|
destFaceGroupID: selectedFaceGroup.id,
|
||||||
|
},
|
||||||
|
}).then(() => {
|
||||||
|
setOpen(false)
|
||||||
|
history.push(`/people/${selectedFaceGroup.id}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFaces = faceGroup?.imageFaces ?? []
|
||||||
|
|
||||||
|
let table = null
|
||||||
|
if (!imagesSelected) {
|
||||||
|
table = (
|
||||||
|
<SelectImageFacesTable
|
||||||
|
imageFaces={imageFaces}
|
||||||
|
selectedImageFaces={selectedImageFaces}
|
||||||
|
setSelectedImageFaces={setSelectedImageFaces}
|
||||||
|
title="Select images to move"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (faceGroupsData) {
|
||||||
|
const filteredFaceGroups = faceGroupsData.myFaceGroups.filter(
|
||||||
|
x => x != faceGroup
|
||||||
|
)
|
||||||
|
table = (
|
||||||
|
<SelectFaceGroupTable
|
||||||
|
title="Select destination face group"
|
||||||
|
faceGroups={filteredFaceGroups}
|
||||||
|
selectedFaceGroup={selectedFaceGroup}
|
||||||
|
setSelectedFaceGroup={setSelectedFaceGroup}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
table = <div>Loading...</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let positiveButton = null
|
||||||
|
if (!imagesSelected) {
|
||||||
|
positiveButton = (
|
||||||
|
<Button
|
||||||
|
disabled={selectedImageFaces.length == 0}
|
||||||
|
content="Next"
|
||||||
|
labelPosition="right"
|
||||||
|
icon="arrow right"
|
||||||
|
onClick={() => setImagesSelected(true)}
|
||||||
|
positive
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
positiveButton = (
|
||||||
|
<Button
|
||||||
|
disabled={!selectedFaceGroup}
|
||||||
|
content="Move image faces"
|
||||||
|
labelPosition="right"
|
||||||
|
icon="checkmark"
|
||||||
|
onClick={() => moveImageFaces()}
|
||||||
|
positive
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onOpen={() => setOpen(true)}
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
|
<Modal.Header>Move Image Faces</Modal.Header>
|
||||||
|
<Modal.Content scrolling>
|
||||||
|
<Modal.Description>
|
||||||
|
<p>Move selected images of this face group to another face group</p>
|
||||||
|
{table}
|
||||||
|
</Modal.Description>
|
||||||
|
</Modal.Content>
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
{positiveButton}
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MoveImageFacesModal.propTypes = {
|
||||||
|
open: PropTypes.bool.isRequired,
|
||||||
|
setOpen: PropTypes.func.isRequired,
|
||||||
|
faceGroup: PropTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MoveImageFacesModal
|
|
@ -0,0 +1,125 @@
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Input, Pagination, Table } from 'semantic-ui-react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import FaceCircleImage from '../FaceCircleImage'
|
||||||
|
|
||||||
|
const FaceCircleWrapper = styled.div`
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid
|
||||||
|
${({ $selected }) => ($selected ? `#2185c9` : 'rgba(255,255,255,0)')};
|
||||||
|
`
|
||||||
|
|
||||||
|
const FlexCell = styled(Table.Cell)`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const RowLabel = styled.span`
|
||||||
|
${({ $selected }) => $selected && `font-weight: bold;`}
|
||||||
|
margin-left: 12px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const FaceGroupRow = ({ faceGroup, faceSelected, setFaceSelected }) => {
|
||||||
|
return (
|
||||||
|
<Table.Row key={faceGroup.id} onClick={setFaceSelected}>
|
||||||
|
<FlexCell>
|
||||||
|
<FaceCircleWrapper $selected={faceSelected}>
|
||||||
|
<FaceCircleImage imageFace={faceGroup.imageFaces[0]} size="50px" />
|
||||||
|
</FaceCircleWrapper>
|
||||||
|
<RowLabel $selected={faceSelected}>{faceGroup.label}</RowLabel>
|
||||||
|
</FlexCell>
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FaceGroupRow.propTypes = {
|
||||||
|
faceGroup: PropTypes.object.isRequired,
|
||||||
|
faceSelected: PropTypes.bool.isRequired,
|
||||||
|
setFaceSelected: PropTypes.func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectFaceGroupTable = ({
|
||||||
|
faceGroups,
|
||||||
|
selectedFaceGroup,
|
||||||
|
setSelectedFaceGroup,
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
const PAGE_SIZE = 6
|
||||||
|
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [searchValue, setSearchValue] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0)
|
||||||
|
}, [searchValue])
|
||||||
|
|
||||||
|
const rows = faceGroups
|
||||||
|
.filter(
|
||||||
|
x =>
|
||||||
|
searchValue == '' ||
|
||||||
|
(x.label && x.label.toLowerCase().includes(searchValue.toLowerCase()))
|
||||||
|
)
|
||||||
|
.map(face => (
|
||||||
|
<FaceGroupRow
|
||||||
|
key={face.id}
|
||||||
|
faceGroup={face}
|
||||||
|
faceSelected={selectedFaceGroup == face}
|
||||||
|
setFaceSelected={() => setSelectedFaceGroup(face)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
const pageRows = rows.filter(
|
||||||
|
(_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table selectable>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell>{title}</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell>
|
||||||
|
<Input
|
||||||
|
value={searchValue}
|
||||||
|
onChange={e => setSearchValue(e.target.value)}
|
||||||
|
icon="search"
|
||||||
|
placeholder="Search faces..."
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>{pageRows}</Table.Body>
|
||||||
|
<Table.Footer>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell>
|
||||||
|
<Pagination
|
||||||
|
floated="right"
|
||||||
|
firstItem={null}
|
||||||
|
lastItem={null}
|
||||||
|
// nextItem={null}
|
||||||
|
// prevItem={null}
|
||||||
|
activePage={page + 1}
|
||||||
|
totalPages={rows.length / PAGE_SIZE}
|
||||||
|
onPageChange={(_, { activePage }) => {
|
||||||
|
setPage(Math.ceil(activePage) - 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Footer>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectFaceGroupTable.propTypes = {
|
||||||
|
faceGroups: PropTypes.array,
|
||||||
|
selectedFaceGroup: PropTypes.object,
|
||||||
|
setSelectedFaceGroup: PropTypes.func.isRequired,
|
||||||
|
title: PropTypes.string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectFaceGroupTable
|
|
@ -0,0 +1,130 @@
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Checkbox, Input, Pagination, Table } from 'semantic-ui-react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
import { ProtectedImage } from '../../../components/photoGallery/ProtectedMedia'
|
||||||
|
import { RowLabel } from './SelectFaceGroupTable'
|
||||||
|
|
||||||
|
const SelectImagePreview = styled(ProtectedImage)`
|
||||||
|
max-width: 120px;
|
||||||
|
max-height: 80px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const ImageFaceRow = ({ imageFace, faceSelected, setFaceSelected }) => {
|
||||||
|
return (
|
||||||
|
<Table.Row key={imageFace.id}>
|
||||||
|
<Table.Cell>
|
||||||
|
<Checkbox checked={faceSelected} onChange={setFaceSelected} />
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<SelectImagePreview
|
||||||
|
src={imageFace.media.thumbnail.url}
|
||||||
|
onClick={setFaceSelected}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell width={16}>
|
||||||
|
<RowLabel $selected={faceSelected} onClick={setFaceSelected}>
|
||||||
|
{imageFace.media.title}
|
||||||
|
</RowLabel>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageFaceRow.propTypes = {
|
||||||
|
imageFace: PropTypes.object.isRequired,
|
||||||
|
faceSelected: PropTypes.bool.isRequired,
|
||||||
|
setFaceSelected: PropTypes.func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectImageFacesTable = ({
|
||||||
|
imageFaces,
|
||||||
|
selectedImageFaces,
|
||||||
|
setSelectedImageFaces,
|
||||||
|
title,
|
||||||
|
}) => {
|
||||||
|
const PAGE_SIZE = 6
|
||||||
|
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [searchValue, setSearchValue] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0)
|
||||||
|
}, [searchValue])
|
||||||
|
|
||||||
|
const rows = imageFaces
|
||||||
|
.filter(
|
||||||
|
face =>
|
||||||
|
searchValue == '' ||
|
||||||
|
face.media.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||||
|
)
|
||||||
|
.map(face => (
|
||||||
|
<ImageFaceRow
|
||||||
|
key={face.id}
|
||||||
|
imageFace={face}
|
||||||
|
faceSelected={selectedImageFaces.includes(face)}
|
||||||
|
setFaceSelected={() =>
|
||||||
|
setSelectedImageFaces(faces => {
|
||||||
|
if (faces.includes(face)) {
|
||||||
|
return faces.filter(x => x != face)
|
||||||
|
} else {
|
||||||
|
return [...faces, face]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
const pageRows = rows.filter(
|
||||||
|
(_, i) => i >= page * PAGE_SIZE && i < (page + 1) * PAGE_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table selectable>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell colSpan={3}>{title}</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell colSpan={3}>
|
||||||
|
<Input
|
||||||
|
value={searchValue}
|
||||||
|
onChange={e => setSearchValue(e.target.value)}
|
||||||
|
icon="search"
|
||||||
|
placeholder="Search images..."
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>{pageRows}</Table.Body>
|
||||||
|
<Table.Footer>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell colSpan={3}>
|
||||||
|
<Pagination
|
||||||
|
floated="right"
|
||||||
|
firstItem={null}
|
||||||
|
lastItem={null}
|
||||||
|
// nextItem={null}
|
||||||
|
// prevItem={null}
|
||||||
|
activePage={page + 1}
|
||||||
|
totalPages={rows.length / PAGE_SIZE}
|
||||||
|
onPageChange={(_, { activePage }) => {
|
||||||
|
setPage(Math.ceil(activePage) - 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Footer>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectImageFacesTable.propTypes = {
|
||||||
|
imageFaces: PropTypes.array,
|
||||||
|
selectedImageFaces: PropTypes.array,
|
||||||
|
setSelectedImageFaces: PropTypes.func.isRequired,
|
||||||
|
title: PropTypes.string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectImageFacesTable
|
|
@ -0,0 +1,47 @@
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import PhotoGallery from '../../../components/photoGallery/PhotoGallery'
|
||||||
|
import FaceGroupTitle from './FaceGroupTitle'
|
||||||
|
|
||||||
|
const SingleFaceGroup = ({ faceGroup }) => {
|
||||||
|
const [presenting, setPresenting] = useState(false)
|
||||||
|
const [activeIndex, setActiveIndex] = useState(-1)
|
||||||
|
|
||||||
|
let mediaGallery = null
|
||||||
|
if (faceGroup) {
|
||||||
|
const media = faceGroup.imageFaces.map(x => x.media)
|
||||||
|
|
||||||
|
const nextImage = () =>
|
||||||
|
setActiveIndex(i => Math.min(i + 1, media.length - 1))
|
||||||
|
|
||||||
|
const previousImage = () => setActiveIndex(i => Math.max(i - 1, 0))
|
||||||
|
|
||||||
|
mediaGallery = (
|
||||||
|
<div>
|
||||||
|
<PhotoGallery
|
||||||
|
media={media}
|
||||||
|
loading={false}
|
||||||
|
presenting={presenting}
|
||||||
|
setPresenting={setPresenting}
|
||||||
|
onSelectImage={setActiveIndex}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
nextImage={nextImage}
|
||||||
|
previousImage={previousImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FaceGroupTitle faceGroup={faceGroup} />
|
||||||
|
{mediaGallery}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SingleFaceGroup.propTypes = {
|
||||||
|
faceGroup: PropTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SingleFaceGroup
|
|
@ -112,7 +112,7 @@ const MapPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout title="Places">
|
||||||
<MapWrapper>
|
<MapWrapper>
|
||||||
<MapContainer ref={mapContainer}></MapContainer>
|
<MapContainer ref={mapContainer}></MapContainer>
|
||||||
</MapWrapper>
|
</MapWrapper>
|
||||||
|
|
|
@ -25,7 +25,7 @@ export const InputLabelDescription = styled.p`
|
||||||
|
|
||||||
const SettingsPage = () => {
|
const SettingsPage = () => {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout title="Settings">
|
||||||
<ScannerSection />
|
<ScannerSection />
|
||||||
<UsersTable />
|
<UsersTable />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
const FaceBoxStyle = styled(Link)`
|
||||||
|
box-shadow: inset 0 0 2px 1px rgba(0, 0, 0, 0.3), 0 0 0 1px rgb(255, 255, 255);
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: ${({ $minY }) => $minY * 100}%;
|
||||||
|
bottom: ${({ $maxY }) => (1 - $maxY) * 100}%;
|
||||||
|
left: ${({ $minX }) => $minX * 100}%;
|
||||||
|
right: ${({ $maxX }) => (1 - $maxX) * 100}%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const FaceBox = ({ face /*media*/ }) => {
|
||||||
|
return (
|
||||||
|
<FaceBoxStyle
|
||||||
|
to={`/people/${face.faceGroup.id}`}
|
||||||
|
$minX={face.rectangle.minX}
|
||||||
|
$maxX={face.rectangle.maxX}
|
||||||
|
$minY={face.rectangle.minY}
|
||||||
|
$maxY={face.rectangle.maxY}
|
||||||
|
></FaceBoxStyle>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FaceBox.propTypes = {
|
||||||
|
face: PropTypes.object.isRequired,
|
||||||
|
media: PropTypes.object.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarFacesOverlayWrapper = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
width: ${({ width }) => width * 100}%;
|
||||||
|
left: ${({ width }) => (100 - width * 100) / 2}%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
transition: opacity ease 200ms;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const SidebarFacesOverlay = ({ media }) => {
|
||||||
|
if (media.type != 'photo') return null
|
||||||
|
|
||||||
|
const faceBoxes = media.faces?.map(face => (
|
||||||
|
<FaceBox key={face.id} face={face} media={media} />
|
||||||
|
))
|
||||||
|
|
||||||
|
let wrapperWidth = 1
|
||||||
|
if (media.thumbnail.width * 0.75 < media.thumbnail.height) {
|
||||||
|
wrapperWidth = (media.thumbnail.width * 0.75) / media.thumbnail.height
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarFacesOverlayWrapper width={wrapperWidth}>
|
||||||
|
{faceBoxes}
|
||||||
|
</SidebarFacesOverlayWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SidebarFacesOverlay.propTypes = {
|
||||||
|
media: PropTypes.object.isRequired,
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ const AlbumPage = React.lazy(() => import('../../Pages/AlbumPage/AlbumPage'))
|
||||||
const PhotosPage = React.lazy(() => import('../../Pages/PhotosPage/PhotosPage'))
|
const PhotosPage = React.lazy(() => import('../../Pages/PhotosPage/PhotosPage'))
|
||||||
const PlacesPage = React.lazy(() => import('../../Pages/PlacesPage/PlacesPage'))
|
const PlacesPage = React.lazy(() => import('../../Pages/PlacesPage/PlacesPage'))
|
||||||
const SharePage = React.lazy(() => import('../../Pages/SharePage/SharePage'))
|
const SharePage = React.lazy(() => import('../../Pages/SharePage/SharePage'))
|
||||||
|
const PeoplePage = React.lazy(() => import('../../Pages/PeoplePage/PeoplePage'))
|
||||||
|
|
||||||
const LoginPage = React.lazy(() => import('../../Pages/LoginPage/LoginPage'))
|
const LoginPage = React.lazy(() => import('../../Pages/LoginPage/LoginPage'))
|
||||||
const InitialSetupPage = React.lazy(() =>
|
const InitialSetupPage = React.lazy(() =>
|
||||||
|
@ -44,9 +45,10 @@ const Routes = () => {
|
||||||
<Route path="/initialSetup" component={InitialSetupPage} />
|
<Route path="/initialSetup" component={InitialSetupPage} />
|
||||||
<Route path="/share" component={SharePage} />
|
<Route path="/share" component={SharePage} />
|
||||||
<AuthorizedRoute exact path="/albums" component={AlbumsPage} />
|
<AuthorizedRoute exact path="/albums" component={AlbumsPage} />
|
||||||
<AuthorizedRoute path="/album/:id/:subPage?" component={AlbumPage} />
|
<AuthorizedRoute path="/album/:id" component={AlbumPage} />
|
||||||
<AuthorizedRoute path="/photos/:subPage?" component={PhotosPage} />
|
<AuthorizedRoute path="/photos" component={PhotosPage} />
|
||||||
<AuthorizedRoute path="/places" component={PlacesPage} />
|
<AuthorizedRoute path="/places" component={PlacesPage} />
|
||||||
|
<AuthorizedRoute path="/people/:person?" component={PeoplePage} />
|
||||||
<AuthorizedRoute admin path="/settings" component={SettingsPage} />
|
<AuthorizedRoute admin path="/settings" component={SettingsPage} />
|
||||||
<Route path="/" exact render={() => <Redirect to="/photos" />} />
|
<Route path="/" exact render={() => <Redirect to="/photos" />} />
|
||||||
<Route render={() => <div>Page not found</div>} />
|
<Route render={() => <div>Page not found</div>} />
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { ProtectedImage, ProtectedVideo } from '../photoGallery/ProtectedMedia'
|
||||||
import SidebarShare from './Sharing'
|
import SidebarShare from './Sharing'
|
||||||
import SidebarDownload from './SidebarDownload'
|
import SidebarDownload from './SidebarDownload'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
|
import { SidebarFacesOverlay } from '../facesOverlay/FacesOverlay'
|
||||||
|
|
||||||
const mediaQuery = gql`
|
const mediaQuery = gql`
|
||||||
query sidebarPhoto($id: ID!) {
|
query sidebarPhoto($id: ID!) {
|
||||||
|
@ -41,6 +42,7 @@ const mediaQuery = gql`
|
||||||
audio
|
audio
|
||||||
}
|
}
|
||||||
exif {
|
exif {
|
||||||
|
id
|
||||||
camera
|
camera
|
||||||
maker
|
maker
|
||||||
lens
|
lens
|
||||||
|
@ -52,6 +54,18 @@ const mediaQuery = gql`
|
||||||
flash
|
flash
|
||||||
exposureProgram
|
exposureProgram
|
||||||
}
|
}
|
||||||
|
faces {
|
||||||
|
id
|
||||||
|
rectangle {
|
||||||
|
minX
|
||||||
|
maxX
|
||||||
|
minY
|
||||||
|
maxY
|
||||||
|
}
|
||||||
|
faceGroup {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
@ -203,6 +217,7 @@ const SidebarContent = ({ media, hidePreview }) => {
|
||||||
{!hidePreview && (
|
{!hidePreview && (
|
||||||
<PreviewImageWrapper imageAspect={imageAspect}>
|
<PreviewImageWrapper imageAspect={imageAspect}>
|
||||||
<PreviewMedia previewImage={previewImage} media={media} />
|
<PreviewMedia previewImage={previewImage} media={media} />
|
||||||
|
<SidebarFacesOverlay media={media} />
|
||||||
</PreviewImageWrapper>
|
</PreviewImageWrapper>
|
||||||
)}
|
)}
|
||||||
<Name>{media && media.title}</Name>
|
<Name>{media && media.title}</Name>
|
||||||
|
|
Loading…
Reference in New Issue