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:
parent
82550fca34
commit
63e58ba035
|
@ -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"`
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue