diff --git a/api/graphql/generated.go b/api/graphql/generated.go index 335e701d..d8e468ed 100644 --- a/api/graphql/generated.go +++ b/api/graphql/generated.go @@ -136,14 +136,18 @@ type ComplexityRoot struct { Mutation struct { AuthorizeUser func(childComplexity int, username string, password string) int + CombineFaceGroups func(childComplexity int, destinationFaceGroupID int, sourceFaceGroupID int) int CreateUser func(childComplexity int, username string, password *string, admin bool) int DeleteShareToken func(childComplexity int, token string) int DeleteUser func(childComplexity int, id int) int FavoriteMedia func(childComplexity int, mediaID int, favorite bool) int InitialSetupWizard func(childComplexity int, username string, password string, rootPath string) int + MoveImageFace func(childComplexity int, imageFaceID int, newFaceGroupID int) int ProtectShareToken func(childComplexity int, token string, password *string) int + RecognizeUnlabeledFaces func(childComplexity int) int ScanAll func(childComplexity int) int ScanUser func(childComplexity int, userID int) int + SetFaceGroupLabel func(childComplexity int, faceGroupID int, label *string) int SetPeriodicScanInterval func(childComplexity int, interval int) int SetScannerConcurrentWorkers func(childComplexity int, workers int) int ShareAlbum func(childComplexity int, albumID int, expire *time.Time, password *string) int @@ -284,6 +288,10 @@ type MutationResolver interface { UserRemoveRootAlbum(ctx context.Context, userID int, albumID int) (*models.Album, error) SetPeriodicScanInterval(ctx context.Context, interval int) (int, error) SetScannerConcurrentWorkers(ctx context.Context, workers int) (int, error) + SetFaceGroupLabel(ctx context.Context, faceGroupID int, label *string) (*models.FaceGroup, error) + CombineFaceGroups(ctx context.Context, destinationFaceGroupID int, sourceFaceGroupID int) (*models.FaceGroup, error) + MoveImageFace(ctx context.Context, imageFaceID int, newFaceGroupID int) (*models.ImageFace, error) + RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) } type QueryResolver interface { SiteInfo(ctx context.Context) (*models.SiteInfo, error) @@ -735,6 +743,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.AuthorizeUser(childComplexity, args["username"].(string), args["password"].(string)), true + case "Mutation.combineFaceGroups": + if e.complexity.Mutation.CombineFaceGroups == nil { + break + } + + args, err := ec.field_Mutation_combineFaceGroups_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.CombineFaceGroups(childComplexity, args["destinationFaceGroupID"].(int), args["sourceFaceGroupID"].(int)), true + case "Mutation.createUser": if e.complexity.Mutation.CreateUser == nil { break @@ -795,6 +815,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.InitialSetupWizard(childComplexity, args["username"].(string), args["password"].(string), args["rootPath"].(string)), true + case "Mutation.moveImageFace": + if e.complexity.Mutation.MoveImageFace == nil { + break + } + + args, err := ec.field_Mutation_moveImageFace_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.MoveImageFace(childComplexity, args["imageFaceID"].(int), args["newFaceGroupID"].(int)), true + case "Mutation.protectShareToken": if e.complexity.Mutation.ProtectShareToken == nil { break @@ -807,6 +839,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ProtectShareToken(childComplexity, args["token"].(string), args["password"].(*string)), true + case "Mutation.recognizeUnlabeledFaces": + if e.complexity.Mutation.RecognizeUnlabeledFaces == nil { + break + } + + return e.complexity.Mutation.RecognizeUnlabeledFaces(childComplexity), true + case "Mutation.scanAll": if e.complexity.Mutation.ScanAll == nil { break @@ -826,6 +865,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.ScanUser(childComplexity, args["userId"].(int)), true + case "Mutation.setFaceGroupLabel": + if e.complexity.Mutation.SetFaceGroupLabel == nil { + break + } + + args, err := ec.field_Mutation_setFaceGroupLabel_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SetFaceGroupLabel(childComplexity, args["faceGroupID"].(int), args["label"].(*string)), true + case "Mutation.setPeriodicScanInterval": if e.complexity.Mutation.SetPeriodicScanInterval == nil { break @@ -1583,6 +1634,15 @@ type Mutation { "Set max number of concurrent scanner jobs running at once" 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 single ImageFace to another face group" + moveImageFace(imageFaceID: ID!, newFaceGroupID: ID!): ImageFace! + "Check all unlabeled faces to see if they match a labeled FaceGroup, and move them if they match" + recognizeUnlabeledFaces: [ImageFace!]! } type Subscription { @@ -1894,6 +1954,30 @@ func (ec *executionContext) field_Mutation_authorizeUser_args(ctx context.Contex return args, nil } +func (ec *executionContext) field_Mutation_combineFaceGroups_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 int + if tmp, ok := rawArgs["destinationFaceGroupID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("destinationFaceGroupID")) + arg0, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["destinationFaceGroupID"] = arg0 + var arg1 int + if tmp, ok := rawArgs["sourceFaceGroupID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("sourceFaceGroupID")) + arg1, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["sourceFaceGroupID"] = arg1 + return args, nil +} + func (ec *executionContext) field_Mutation_createUser_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2014,6 +2098,30 @@ func (ec *executionContext) field_Mutation_initialSetupWizard_args(ctx context.C return args, nil } +func (ec *executionContext) field_Mutation_moveImageFace_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 int + if tmp, ok := rawArgs["imageFaceID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("imageFaceID")) + arg0, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["imageFaceID"] = arg0 + var arg1 int + if tmp, ok := rawArgs["newFaceGroupID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("newFaceGroupID")) + arg1, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["newFaceGroupID"] = arg1 + return args, nil +} + func (ec *executionContext) field_Mutation_protectShareToken_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -2053,6 +2161,30 @@ func (ec *executionContext) field_Mutation_scanUser_args(ctx context.Context, ra return args, nil } +func (ec *executionContext) field_Mutation_setFaceGroupLabel_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 int + if tmp, ok := rawArgs["faceGroupID"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("faceGroupID")) + arg0, err = ec.unmarshalNID2int(ctx, tmp) + if err != nil { + return nil, err + } + } + args["faceGroupID"] = arg0 + var arg1 *string + if tmp, ok := rawArgs["label"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("label")) + arg1, err = ec.unmarshalOString2ᚖstring(ctx, tmp) + if err != nil { + return nil, err + } + } + args["label"] = arg1 + return args, nil +} + func (ec *executionContext) field_Mutation_setPeriodicScanInterval_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -5184,6 +5316,167 @@ func (ec *executionContext) _Mutation_setScannerConcurrentWorkers(ctx context.Co return ec.marshalNInt2int(ctx, field.Selections, res) } +func (ec *executionContext) _Mutation_setFaceGroupLabel(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_setFaceGroupLabel_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().SetFaceGroupLabel(rctx, args["faceGroupID"].(int), args["label"].(*string)) + }) + 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.FaceGroup) + fc.Result = res + return ec.marshalNFaceGroup2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_combineFaceGroups(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_combineFaceGroups_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().CombineFaceGroups(rctx, args["destinationFaceGroupID"].(int), args["sourceFaceGroupID"].(int)) + }) + 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.FaceGroup) + fc.Result = res + return ec.marshalNFaceGroup2ᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx, field.Selections, res) +} + +func (ec *executionContext) _Mutation_moveImageFace(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + Field: field, + Args: nil, + IsMethod: true, + IsResolver: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Mutation_moveImageFace_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().MoveImageFace(rctx, args["imageFaceID"].(int), args["newFaceGroupID"].(int)) + }) + 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) _Mutation_recognizeUnlabeledFaces(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Mutation", + 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.Mutation().RecognizeUnlabeledFaces(rctx) + }) + 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) _Notification_key(ctx context.Context, field graphql.CollectedField, obj *models.Notification) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -9252,6 +9545,26 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { invalids++ } + case "setFaceGroupLabel": + out.Values[i] = ec._Mutation_setFaceGroupLabel(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "combineFaceGroups": + out.Values[i] = ec._Mutation_combineFaceGroups(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "moveImageFace": + out.Values[i] = ec._Mutation_moveImageFace(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } + case "recognizeUnlabeledFaces": + out.Values[i] = ec._Mutation_recognizeUnlabeledFaces(ctx, field) + if out.Values[i] == graphql.Null { + invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -10253,6 +10566,10 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) marshalNFaceGroup2githubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroup(ctx context.Context, sel ast.SelectionSet, v models.FaceGroup) graphql.Marshaler { + return ec._FaceGroup(ctx, sel, &v) +} + func (ec *executionContext) marshalNFaceGroup2ᚕᚖgithubᚗcomᚋphotoviewᚋphotoviewᚋapiᚋgraphqlᚋmodelsᚐFaceGroupᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.FaceGroup) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup diff --git a/api/graphql/resolvers/faces.go b/api/graphql/resolvers/faces.go index de4328fb..a2fc6b84 100644 --- a/api/graphql/resolvers/faces.go +++ b/api/graphql/resolvers/faces.go @@ -52,7 +52,8 @@ func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagin } faceGroupQuery := r.Database. - Where("id IN (?)", faceGroupIDs). + 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 @@ -66,3 +67,55 @@ func (r *queryResolver) MyFaceGroups(ctx context.Context, paginate *models.Pagin 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") + } + + 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 + } + + // Verify that user owns at leat one of the images in the face group + imageFaceQuery := r.Database. + 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 := r.Database. + 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 { + 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) { + panic("not implemented") +} + +func (r *mutationResolver) MoveImageFace(ctx context.Context, imageFaceID int, newFaceGroupID int) (*models.ImageFace, error) { + panic("not implemented") +} + +func (r *mutationResolver) RecognizeUnlabeledFaces(ctx context.Context) ([]*models.ImageFace, error) { + panic("not implemented") +} diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index bf4fdb88..cc10c9bd 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -115,6 +115,15 @@ type Mutation { "Set max number of concurrent scanner jobs running at once" 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 single ImageFace to another face group" + moveImageFace(imageFaceID: ID!, newFaceGroupID: ID!): ImageFace! + "Check all unlabeled faces to see if they match a labeled FaceGroup, and move them if they match" + recognizeUnlabeledFaces: [ImageFace!]! } type Subscription { diff --git a/ui/src/Layout.js b/ui/src/Layout.js index fc14acb5..9b9ad388 100644 --- a/ui/src/Layout.js +++ b/ui/src/Layout.js @@ -104,21 +104,21 @@ export const SideMenu = () => { return ( - + Photos - + Albums {mapboxEnabled ? ( - + Places ) : null} - + People {isAdmin ? ( diff --git a/ui/src/Pages/PeoplePage/PeoplePage.js b/ui/src/Pages/PeoplePage/PeoplePage.js index 1a92dbed..f0b34a42 100644 --- a/ui/src/Pages/PeoplePage/PeoplePage.js +++ b/ui/src/Pages/PeoplePage/PeoplePage.js @@ -44,7 +44,7 @@ const CircleImageWrapper = styled.div` overflow: hidden; ` -const FaceImage = styled(ProtectedImage)` +const FaceImagePortrait = styled(ProtectedImage)` position: absolute; width: 100%; top: 50%; @@ -52,7 +52,21 @@ const FaceImage = styled(ProtectedImage)` ${({ origin, scale }) => `translate(${(0.5 - origin.x) * 100}%, ${ (0.5 - origin.y) * 100 - }%) scale(${scale * 0.8})`}; + }%) scale(${Math.max(scale * 0.8, 1)})`}; + + transform-origin: ${({ origin }) => `${origin.x * 100}% ${origin.y * 100}%`}; + object-fit: cover; +` + +const FaceImageLandscape = styled(ProtectedImage)` + position: absolute; + 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)})`}; transform-origin: ${({ origin }) => `${origin.x * 100}% ${origin.y * 100}%`}; object-fit: cover; @@ -76,6 +90,11 @@ const FaceGroup = ({ group }) => { y: (rect.minY + rect.maxY) / 2, } + const FaceImage = + previewFace.media.thumbnail.width > previewFace.media.thumbnail.height + ? FaceImageLandscape + : FaceImagePortrait + return (