diff --git a/api/gqlgen.yml b/api/gqlgen.yml index 484a4b23..73a286af 100644 --- a/api/gqlgen.yml +++ b/api/gqlgen.yml @@ -32,6 +32,8 @@ models: fields: exif: resolver: true + faces: + resolver: true MediaURL: model: github.com/photoview/photoview/api/graphql/models.MediaURL MediaEXIF: diff --git a/api/graphql/generated.go b/api/graphql/generated.go index a77b6ed9..335e701d 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -94,6 +94,7 @@ type ComplexityRoot struct { Album func(childComplexity int) int Downloads func(childComplexity int) int Exif func(childComplexity int) int + Faces func(childComplexity int) int Favorite func(childComplexity int) int HighRes func(childComplexity int) int ID func(childComplexity int) int @@ -264,6 +265,7 @@ type MediaResolver interface { Shares(ctx context.Context, obj *models.Media) ([]*models.ShareToken, error) Downloads(ctx context.Context, obj *models.Media) ([]*models.MediaDownload, error) + Faces(ctx context.Context, obj *models.Media) ([]*models.ImageFace, error) } type MutationResolver interface { AuthorizeUser(ctx context.Context, username string, password string) (*models.AuthorizeResult, error) @@ -518,6 +520,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Media.Exif(childComplexity), true + case "Media.faces": + if e.complexity.Media.Faces == nil { + break + } + + return e.complexity.Media.Faces(childComplexity), true + case "Media.favorite": if e.complexity.Media.Favorite == nil { break @@ -1723,6 +1732,8 @@ type Media { shares: [ShareToken!]! downloads: [MediaDownload!]! + + faces: [ImageFace!]! } "EXIF metadata from the camera" @@ -3786,6 +3797,41 @@ func (ec *executionContext) _Media_downloads(ctx context.Context, field graphql. return ec.marshalNMediaDownload2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐMediaDownloadᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _Media_faces(ctx context.Context, field graphql.CollectedField, obj *models.Media) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Media", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Media().Faces(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*models.ImageFace) + fc.Result = res + return ec.marshalNImageFace2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFaceᚄ(ctx, field.Selections, res) +} + func (ec *executionContext) _MediaDownload_title(ctx context.Context, field graphql.CollectedField, obj *models.MediaDownload) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -8993,6 +9039,20 @@ func (ec *executionContext) _Media(ctx context.Context, sel ast.SelectionSet, ob } return res }) + case "faces": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Media_faces(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -10341,6 +10401,53 @@ func (ec *executionContext) marshalNImageFace2ᚕgithubᚗcomᚋphotoviewᚋphot return ret } +func (ec *executionContext) marshalNImageFace2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFaceᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.ImageFace) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNImageFace2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFace(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + return ret +} + +func (ec *executionContext) marshalNImageFace2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐImageFace(ctx context.Context, sel ast.SelectionSet, v *models.ImageFace) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + return ec._ImageFace(ctx, sel, v) +} + func (ec *executionContext) unmarshalNInt2int(ctx context.Context, v interface{}) (int, error) { res, err := graphql.UnmarshalInt(v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/api/graphql/models/media.go b/api/graphql/models/media.go index 014fd750..cd8e093d 100644 --- a/api/graphql/models/media.go +++ b/api/graphql/models/media.go @@ -27,7 +27,8 @@ type Media struct { VideoMetadataID *int `gorm:"index"` VideoMetadata *VideoMetadata `gorm:"constraint:OnDelete:CASCADE;"` SideCarPath *string - SideCarHash *string `gorm:"unique"` + SideCarHash *string `gorm:"unique"` + Faces []*ImageFace `gorm:"constraint:OnDelete:CASCADE;"` // Only used internally CounterpartPath *string `gorm:"-"` diff --git a/api/graphql/resolvers/faces.go b/api/graphql/resolvers/faces.go index b99b3437..de4328fb 100644 --- a/api/graphql/resolvers/faces.go +++ b/api/graphql/resolvers/faces.go @@ -23,8 +23,12 @@ func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagin userAlbumIDs[i] = album.ID } + imageFaceQuery := r.Database. + Joins("Media"). + Where("media.album_id IN (?)", userAlbumIDs) + var imageFaces []*models.ImageFace - if err := r.Database.Joins("Media").Where("media.album_id IN (?)", userAlbumIDs).Find(&imageFaces).Error; err != nil { + if err := imageFaceQuery.Find(&imageFaces).Error; err != nil { return nil, err } @@ -47,8 +51,12 @@ func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagin i++ } + faceGroupQuery := r.Database. + Where("id IN (?)", faceGroupIDs). + Order("CASE WHEN label IS NULL THEN 1 ELSE 0 END") + var faceGroups []*models.FaceGroup - if err := r.Database.Where("id IN (?)", faceGroupIDs).Find(&faceGroups).Error; err != nil { + if err := faceGroupQuery.Find(&faceGroups).Error; err != nil { return nil, err } diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go index bb0d2706..b32117fb 100644 --- a/api/graphql/resolvers/media.go +++ b/api/graphql/resolvers/media.go @@ -208,3 +208,16 @@ func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favor 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 +} diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 88a22fdb..bf4fdb88 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -264,6 +264,8 @@ type Media { shares: [ShareToken!]! downloads: [MediaDownload!]! + + faces: [ImageFace!]! } "EXIF metadata from the camera" diff --git a/ui/src/Pages/PeoplePage/PeoplePage.js b/ui/src/Pages/PeoplePage/PeoplePage.js index dfe41eb9..73a28c8a 100644 --- a/ui/src/Pages/PeoplePage/PeoplePage.js +++ b/ui/src/Pages/PeoplePage/PeoplePage.js @@ -48,11 +48,18 @@ const FaceImage = styled(ProtectedImage)` object-fit: cover; ` +const FaceLabel = styled.div` + color: ${({ labeled }) => (labeled ? 'black' : '#aaa')}; + margin: 12px 12px 24px; + text-align: center; +` + const FaceGroup = ({ group }) => ( + {group.label ?? 'Unlabeled'} ) diff --git a/ui/src/Pages/PeoplePage/SingleFaceGroup.js b/ui/src/Pages/PeoplePage/SingleFaceGroup.js index 0811ac18..6becb53c 100644 --- a/ui/src/Pages/PeoplePage/SingleFaceGroup.js +++ b/ui/src/Pages/PeoplePage/SingleFaceGroup.js @@ -31,7 +31,12 @@ const SingleFaceGroup = ({ faceGroup }) => {
Face group: {faceGroup.id}
- {}} /> + {}} + onSelectImage={() => {}} + />
) diff --git a/ui/src/components/facesOverlay/FacesOverlay.js b/ui/src/components/facesOverlay/FacesOverlay.js new file mode 100644 index 00000000..d6536673 --- /dev/null +++ b/ui/src/components/facesOverlay/FacesOverlay.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types' +import React from 'react' +import styled from 'styled-components' + +const FaceBoxStyle = styled.div` + 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 +} + +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 => ( + + )) + + let wrapperWidth = 1 + if (media.thumbnail.width * 0.75 < media.thumbnail.height) { + wrapperWidth = (media.thumbnail.width * 0.75) / media.thumbnail.height + } + + return ( + + {faceBoxes} + + ) +} + +SidebarFacesOverlay.propTypes = { + media: PropTypes.object.isRequired, +} diff --git a/ui/src/components/sidebar/MediaSidebar.js b/ui/src/components/sidebar/MediaSidebar.js index 4d4a7384..4e88a0db 100644 --- a/ui/src/components/sidebar/MediaSidebar.js +++ b/ui/src/components/sidebar/MediaSidebar.js @@ -7,6 +7,7 @@ import { ProtectedImage, ProtectedVideo } from '../photoGallery/ProtectedMedia' import SidebarShare from './Sharing' import SidebarDownload from './SidebarDownload' import SidebarItem from './SidebarItem' +import { SidebarFacesOverlay } from '../facesOverlay/FacesOverlay' const mediaQuery = gql` query sidebarPhoto($id: ID!) { @@ -41,6 +42,7 @@ const mediaQuery = gql` audio } exif { + id camera maker lens @@ -52,6 +54,15 @@ const mediaQuery = gql` flash exposureProgram } + faces { + id + rectangle { + minX + maxX + minY + maxY + } + } } } ` @@ -203,6 +214,7 @@ const SidebarContent = ({ media, hidePreview }) => { {!hidePreview && ( + )} {media && media.title}