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
This commit is contained in:
Stefan 2022-04-15 20:20:12 +02:00 committed by Stefan Dunca
parent 82550fca34
commit 63e58ba035
10 changed files with 204 additions and 14 deletions

9
images/cropped_image.go Normal file
View File

@ -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"`
}

View File

@ -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

View File

@ -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
}

46
images/main_test.go Normal file
View File

@ -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)
}

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}

View File

@ -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)

View File

@ -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{

View File

@ -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,9 +69,10 @@ func (c *CreateCommunity) ToCommunityDescription() (*protobuf.CommunityDescripti
Description: c.Description,
}
if c.Image != "" || c.Banner.ImagePath != "" {
ciis := make(map[string]*protobuf.IdentityImage)
if c.Image != "" {
log.Info("has-image", "image", c.Image)
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
@ -78,6 +80,15 @@ func (c *CreateCommunity) ToCommunityDescription() (*protobuf.CommunityDescripti
for _, img := range imgs {
ciis[img.Name] = adaptIdentityImageToProtobuf(img)
}
}
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
log.Info("set images", "images", ci)
}