From 63e58ba0354448b67449053b4599b2974d398a11 Mon Sep 17 00:00:00 2001 From: Stefan Date: Fri, 15 Apr 2022 20:20:12 +0200 Subject: [PATCH] feat: add banner support for communities Add banner image as a special `IdentityImage` beside "thumbnail" and "large" Banner input cropped image processing - Resize to keep in the limits of `BannerDim` - Encode to match the file size limits define for banner - Don't scale up. This can be done efficiently in the UI Changes to `images` module - Refactor `EncodeToBestSize` as `EncodeToLimits` to accept arbitrary dimensions and allow for custom size - Define `DimensionLimits` for banner not to exceed 450 KB and a rough estimate for the ideal size --- images/cropped_image.go | 9 ++++ images/encode.go | 12 +++-- images/main.go | 38 +++++++++++++++ images/main_test.go | 46 +++++++++++++++++++ images/manipulation.go | 6 +++ images/manipulation_test.go | 16 +++++++ images/meta.go | 11 +++++ protocol/communities/manager.go | 15 ++++-- protocol/communities/manager_test.go | 42 +++++++++++++++++ protocol/requests/create_community_request.go | 23 +++++++--- 10 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 images/cropped_image.go create mode 100644 images/main_test.go diff --git a/images/cropped_image.go b/images/cropped_image.go new file mode 100644 index 000000000..39e4869bb --- /dev/null +++ b/images/cropped_image.go @@ -0,0 +1,9 @@ +package images + +type CroppedImage struct { + ImagePath string `json:"imagePath"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} diff --git a/images/encode.go b/images/encode.go index b145c2bd6..ba4213786 100644 --- a/images/encode.go +++ b/images/encode.go @@ -25,7 +25,7 @@ func renderJpeg(w io.Writer, m image.Image, config EncodeConfig) error { return jpeg.Encode(w, m, o) } -func EncodeToBestSize(bb *bytes.Buffer, img image.Image, size ResizeDimension) error { +func EncodeToLimits(bb *bytes.Buffer, img image.Image, bounds DimensionLimits) error { q := MaxJpegQuality for q > MinJpegQuality-1 { @@ -34,17 +34,17 @@ func EncodeToBestSize(bb *bytes.Buffer, img image.Image, size ResizeDimension) e return err } - if DimensionSizeLimit[size].Ideal > bb.Len() { + if bounds.Ideal > bb.Len() { return nil } if q == MinJpegQuality { - if DimensionSizeLimit[size].Max > bb.Len() { + if bounds.Max > bb.Len() { return nil } return fmt.Errorf( "image size after processing exceeds max, expect < '%d', received < '%d'", - DimensionSizeLimit[size].Max, + bounds.Max, bb.Len(), ) } @@ -56,6 +56,10 @@ func EncodeToBestSize(bb *bytes.Buffer, img image.Image, size ResizeDimension) e return nil } +func EncodeToBestSize(bb *bytes.Buffer, img image.Image, size ResizeDimension) error { + return EncodeToLimits(bb, img, DimensionSizeLimit[size]) +} + func GetPayloadDataURI(payload []byte) (string, error) { if len(payload) == 0 { return "", nil diff --git a/images/main.go b/images/main.go index 64ac0e1a0..e95769a26 100644 --- a/images/main.go +++ b/images/main.go @@ -64,3 +64,41 @@ func GenerateIdentityImagesFromURL(url string) ([]*IdentityImage, error) { return GenerateImageVariants(cImg) } + +func GenerateBannerImage(filepath string, aX, aY, bX, bY int) (*IdentityImage, error) { + img, err := Decode(filepath) + if err != nil { + return nil, err + } + + cropRect := image.Rectangle{ + Min: image.Point{X: aX, Y: aY}, + Max: image.Point{X: bX, Y: bY}, + } + croppedImg, err := Crop(img, cropRect) + if err != nil { + return nil, err + } + + dimension := BannerDim + resizedImg := ShrinkOnly(dimension, croppedImg) + + sizeLimits := GetBannerDimensionLimits() + + bb := bytes.NewBuffer([]byte{}) + err = EncodeToLimits(bb, resizedImg, sizeLimits) + if err != nil { + return nil, err + } + + ii := &IdentityImage{ + Name: BannerIdentityName, + Payload: bb.Bytes(), + Width: resizedImg.Bounds().Dx(), + Height: resizedImg.Bounds().Dy(), + FileSize: bb.Len(), + ResizeTarget: int(dimension), + } + + return ii, nil +} diff --git a/images/main_test.go b/images/main_test.go new file mode 100644 index 000000000..97c311030 --- /dev/null +++ b/images/main_test.go @@ -0,0 +1,46 @@ +package images + +import ( + "image" + "image/png" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateBannerImage_NoScaleUp(t *testing.T) { + // Test image 256x256 + testImage := path + "status.png" + identityImage, err := GenerateBannerImage(testImage, 50, 50, 150, 100) + + require.NoError(t, err) + require.Exactly(t, identityImage.Name, BannerIdentityName) + require.Positive(t, len(identityImage.Payload)) + // Ensure we don't scale it up. That will be done inefficiently in backend instead of frontend + require.Exactly(t, identityImage.Width, 100) + require.Exactly(t, identityImage.Height, 50) + require.Exactly(t, identityImage.FileSize, len(identityImage.Payload)) + require.Exactly(t, identityImage.ResizeTarget, int(BannerDim)) +} + +func TestGenerateBannerImage_ShrinkOnly(t *testing.T) { + // Generate test image bigger than BannerDim + testImage := image.NewRGBA(image.Rect(0, 0, int(BannerDim)+10, int(BannerDim)+20)) + + tmpTestFilePath := t.TempDir() + "/test.png" + file, err := os.Create(tmpTestFilePath) + require.NoError(t, err) + defer file.Close() + + err = png.Encode(file, testImage) + require.NoError(t, err) + + identityImage, error := GenerateBannerImage(tmpTestFilePath, 0, 0, int(BannerDim)+5, int(BannerDim)+10) + + require.NoError(t, error) + require.Positive(t, len(identityImage.Payload)) + // Ensure we scale it down by the small side + require.Exactly(t, identityImage.Width, int(BannerDim)) + require.Exactly(t, identityImage.Height, 805) +} diff --git a/images/manipulation.go b/images/manipulation.go index 01a8bd0fd..a954291c0 100644 --- a/images/manipulation.go +++ b/images/manipulation.go @@ -3,6 +3,7 @@ package images import ( "fmt" "image" + "math" "github.com/nfnt/resize" "github.com/oliamb/cutter" @@ -27,6 +28,11 @@ func Resize(size ResizeDimension, img image.Image) image.Image { return resize.Resize(width, height, img, resize.Bilinear) } +func ShrinkOnly(size ResizeDimension, img image.Image) image.Image { + finalSize := int(math.Min(float64(size), math.Min(float64(img.Bounds().Dx()), float64(img.Bounds().Dy())))) + return Resize(ResizeDimension(finalSize), img) +} + func Crop(img image.Image, rect image.Rectangle) (image.Image, error) { if img.Bounds().Max.X < rect.Max.X || img.Bounds().Max.Y < rect.Max.Y { diff --git a/images/manipulation_test.go b/images/manipulation_test.go index 8d79565e3..d35ff9c49 100644 --- a/images/manipulation_test.go +++ b/images/manipulation_test.go @@ -97,6 +97,22 @@ func TestResize(t *testing.T) { } } +// Requirement of ShrinkOnly +func TestThatResizeResizesSmallerHeightByTheSmallestSide(t *testing.T) { + image := image.NewRGBA(image.Rect(0, 0, 4, 2)) + resizedImage := Resize(ResizeDimension(1), image) + require.Exactly(t, 2, resizedImage.Bounds().Dx()) + require.Exactly(t, 1, resizedImage.Bounds().Dy()) +} + +// Requirement of ShrinkOnly +func TestThatResizeResizesSmallerWidthByTheSmallestSide(t *testing.T) { + image := image.NewRGBA(image.Rect(0, 0, 4, 8)) + resizedImage := Resize(ResizeDimension(2), image) + require.Exactly(t, 2, resizedImage.Bounds().Dx()) + require.Exactly(t, 4, resizedImage.Bounds().Dy()) +} + func TestCrop(t *testing.T) { type params struct { Rectangle image.Rectangle diff --git a/images/meta.go b/images/meta.go index 18f7d0a13..117775cdd 100644 --- a/images/meta.go +++ b/images/meta.go @@ -17,8 +17,12 @@ const ( SmallDim = ResizeDimension(80) LargeDim = ResizeDimension(240) + BannerDim = ResizeDimension(800) + SmallDimName = "thumbnail" LargeDimName = "large" + + BannerIdentityName = "banner" ) var ( @@ -58,3 +62,10 @@ type DimensionLimits struct { type ImageType uint type ResizeDimension uint + +func GetBannerDimensionLimits() DimensionLimits { + return DimensionLimits{ + Ideal: 307200, // We want to save space and traffic but keep to maximum compression + Max: 460800, // Can't go bigger than 450 KB + } +} diff --git a/protocol/communities/manager.go b/protocol/communities/manager.go index 5042fdad0..18ba056e5 100644 --- a/protocol/communities/manager.go +++ b/protocol/communities/manager.go @@ -303,7 +303,7 @@ func (m *Manager) CreateCommunity(request *requests.CreateCommunity) (*Community return community, nil } -// CreateCommunity takes a description, updates the community with the description, +// EditCommunity takes a description, updates the community with the description, // saves it and returns it func (m *Manager) EditCommunity(request *requests.EditCommunity) (*Community, error) { community, err := m.GetByID(request.CommunityID) @@ -326,11 +326,18 @@ func (m *Manager) EditCommunity(request *requests.EditCommunity) (*Community, er if newDescription.Permissions.Access == protobuf.CommunityPermissions_UNKNOWN_ACCESS { newDescription.Permissions.Access = community.config.CommunityDescription.Permissions.Access } - // If the image wasn't edited, use the existing one + // Use existing images for the entries that were not updated // NOTE: This will NOT allow deletion of the community image; it will need to // be handled separately. - if request.Image == "" { - newDescription.Identity.Images = community.config.CommunityDescription.Identity.Images + for imageName := range community.config.CommunityDescription.Identity.Images { + _, exists := newDescription.Identity.Images[imageName] + if !exists { + // If no image was set in ToCommunityDescription then Images is nil. + if newDescription.Identity.Images == nil { + newDescription.Identity.Images = make(map[string]*protobuf.IdentityImage) + } + newDescription.Identity.Images[imageName] = community.config.CommunityDescription.Identity.Images[imageName] + } } // TODO: handle delete image (if needed) diff --git a/protocol/communities/manager_test.go b/protocol/communities/manager_test.go index ff0ae81bf..cdd4c6112 100644 --- a/protocol/communities/manager_test.go +++ b/protocol/communities/manager_test.go @@ -2,6 +2,8 @@ package communities import ( "bytes" + "image" + "image/png" "io/ioutil" "os" "testing" @@ -9,6 +11,7 @@ import ( "github.com/status-im/status-go/appdatabase" "github.com/status-im/status-go/eth-node/types" + userimages "github.com/status-im/status-go/images" "github.com/status-im/status-go/params" "github.com/status-im/status-go/protocol/requests" "github.com/status-im/status-go/protocol/transport" @@ -76,6 +79,45 @@ func (s *ManagerSuite) TestCreateCommunity() { s.Require().True(proto.Equal(community.config.CommunityDescription, actualCommunity.config.CommunityDescription)) } +func (s *ManagerSuite) TestCreateCommunity_WithBanner() { + // Generate test image bigger than BannerDim + testImage := image.NewRGBA(image.Rect(0, 0, 20, 10)) + + tmpTestFilePath := s.T().TempDir() + "/test.png" + file, err := os.Create(tmpTestFilePath) + s.NoError(err) + defer file.Close() + + err = png.Encode(file, testImage) + s.Require().NoError(err) + + request := &requests.CreateCommunity{ + Name: "with_banner", + Description: "community with banner ", + Membership: protobuf.CommunityPermissions_NO_MEMBERSHIP, + Banner: userimages.CroppedImage{ + ImagePath: tmpTestFilePath, + X: 1, + Y: 1, + Width: 10, + Height: 5, + }, + } + + community, err := s.manager.CreateCommunity(request) + s.Require().NoError(err) + s.Require().NotNil(community) + + communities, err := s.manager.All() + s.Require().NoError(err) + // Consider status default community + s.Require().Len(communities, 2) + s.Require().Equal(len(community.config.CommunityDescription.Identity.Images), 1) + testIdentityImage, isMapContainsKey := community.config.CommunityDescription.Identity.Images[userimages.BannerIdentityName] + s.Require().True(isMapContainsKey) + s.Require().Positive(len(testIdentityImage.Payload)) +} + func (s *ManagerSuite) TestEditCommunity() { //create community createRequest := &requests.CreateCommunity{ diff --git a/protocol/requests/create_community_request.go b/protocol/requests/create_community_request.go index 6c478ca21..4c245071c 100644 --- a/protocol/requests/create_community_request.go +++ b/protocol/requests/create_community_request.go @@ -28,6 +28,7 @@ type CreateCommunity struct { ImageAy int `json:"imageAy"` ImageBx int `json:"imageBx"` ImageBy int `json:"imageBy"` + Banner userimages.CroppedImage `json:"banner"` HistoryArchiveSupportEnabled bool `json:"historyArchiveSupportEnabled,omitempty"` PinMessageAllMembersEnabled bool `json:"pinMessageAllMembersEnabled,omitempty"` } @@ -68,14 +69,24 @@ func (c *CreateCommunity) ToCommunityDescription() (*protobuf.CommunityDescripti Description: c.Description, } - if c.Image != "" { - log.Info("has-image", "image", c.Image) + if c.Image != "" || c.Banner.ImagePath != "" { ciis := make(map[string]*protobuf.IdentityImage) - imgs, err := userimages.GenerateIdentityImages(c.Image, c.ImageAx, c.ImageAy, c.ImageBx, c.ImageBy) - if err != nil { - return nil, err + if c.Image != "" { + log.Info("has-image", "image", c.Image) + imgs, err := userimages.GenerateIdentityImages(c.Image, c.ImageAx, c.ImageAy, c.ImageBx, c.ImageBy) + if err != nil { + return nil, err + } + for _, img := range imgs { + ciis[img.Name] = adaptIdentityImageToProtobuf(img) + } } - for _, img := range imgs { + if c.Banner.ImagePath != "" { + log.Info("has-banner", "image", c.Banner.ImagePath) + img, err := userimages.GenerateBannerImage(c.Banner.ImagePath, c.Banner.X, c.Banner.Y, c.Banner.X+c.Banner.Width, c.Banner.Y+c.Banner.Height) + if err != nil { + return nil, err + } ciis[img.Name] = adaptIdentityImageToProtobuf(img) } ci.Images = ciis