mirror of
https://github.com/status-im/status-go.git
synced 2025-02-16 16:56:53 +00:00
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
9
images/cropped_image.go
Normal file
9
images/cropped_image.go
Normal 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"`
|
||||||
|
}
|
@ -25,7 +25,7 @@ func renderJpeg(w io.Writer, m image.Image, config EncodeConfig) error {
|
|||||||
return jpeg.Encode(w, m, o)
|
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
|
q := MaxJpegQuality
|
||||||
for q > MinJpegQuality-1 {
|
for q > MinJpegQuality-1 {
|
||||||
|
|
||||||
@ -34,17 +34,17 @@ func EncodeToBestSize(bb *bytes.Buffer, img image.Image, size ResizeDimension) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if DimensionSizeLimit[size].Ideal > bb.Len() {
|
if bounds.Ideal > bb.Len() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if q == MinJpegQuality {
|
if q == MinJpegQuality {
|
||||||
if DimensionSizeLimit[size].Max > bb.Len() {
|
if bounds.Max > bb.Len() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"image size after processing exceeds max, expect < '%d', received < '%d'",
|
"image size after processing exceeds max, expect < '%d', received < '%d'",
|
||||||
DimensionSizeLimit[size].Max,
|
bounds.Max,
|
||||||
bb.Len(),
|
bb.Len(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -56,6 +56,10 @@ func EncodeToBestSize(bb *bytes.Buffer, img image.Image, size ResizeDimension) e
|
|||||||
return nil
|
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) {
|
func GetPayloadDataURI(payload []byte) (string, error) {
|
||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
|
@ -64,3 +64,41 @@ func GenerateIdentityImagesFromURL(url string) ([]*IdentityImage, error) {
|
|||||||
|
|
||||||
return GenerateImageVariants(cImg)
|
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
46
images/main_test.go
Normal 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)
|
||||||
|
}
|
@ -3,6 +3,7 @@ package images
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
|
"math"
|
||||||
|
|
||||||
"github.com/nfnt/resize"
|
"github.com/nfnt/resize"
|
||||||
"github.com/oliamb/cutter"
|
"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)
|
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) {
|
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 {
|
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) {
|
func TestCrop(t *testing.T) {
|
||||||
type params struct {
|
type params struct {
|
||||||
Rectangle image.Rectangle
|
Rectangle image.Rectangle
|
||||||
|
@ -17,8 +17,12 @@ const (
|
|||||||
SmallDim = ResizeDimension(80)
|
SmallDim = ResizeDimension(80)
|
||||||
LargeDim = ResizeDimension(240)
|
LargeDim = ResizeDimension(240)
|
||||||
|
|
||||||
|
BannerDim = ResizeDimension(800)
|
||||||
|
|
||||||
SmallDimName = "thumbnail"
|
SmallDimName = "thumbnail"
|
||||||
LargeDimName = "large"
|
LargeDimName = "large"
|
||||||
|
|
||||||
|
BannerIdentityName = "banner"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -58,3 +62,10 @@ type DimensionLimits struct {
|
|||||||
|
|
||||||
type ImageType uint
|
type ImageType uint
|
||||||
type ResizeDimension 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
|
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
|
// saves it and returns it
|
||||||
func (m *Manager) EditCommunity(request *requests.EditCommunity) (*Community, error) {
|
func (m *Manager) EditCommunity(request *requests.EditCommunity) (*Community, error) {
|
||||||
community, err := m.GetByID(request.CommunityID)
|
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 {
|
if newDescription.Permissions.Access == protobuf.CommunityPermissions_UNKNOWN_ACCESS {
|
||||||
newDescription.Permissions.Access = community.config.CommunityDescription.Permissions.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
|
// NOTE: This will NOT allow deletion of the community image; it will need to
|
||||||
// be handled separately.
|
// be handled separately.
|
||||||
if request.Image == "" {
|
for imageName := range community.config.CommunityDescription.Identity.Images {
|
||||||
newDescription.Identity.Images = 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)
|
// TODO: handle delete image (if needed)
|
||||||
|
|
||||||
|
@ -2,6 +2,8 @@ package communities
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
@ -9,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/status-im/status-go/appdatabase"
|
"github.com/status-im/status-go/appdatabase"
|
||||||
"github.com/status-im/status-go/eth-node/types"
|
"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/params"
|
||||||
"github.com/status-im/status-go/protocol/requests"
|
"github.com/status-im/status-go/protocol/requests"
|
||||||
"github.com/status-im/status-go/protocol/transport"
|
"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))
|
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() {
|
func (s *ManagerSuite) TestEditCommunity() {
|
||||||
//create community
|
//create community
|
||||||
createRequest := &requests.CreateCommunity{
|
createRequest := &requests.CreateCommunity{
|
||||||
|
@ -28,6 +28,7 @@ type CreateCommunity struct {
|
|||||||
ImageAy int `json:"imageAy"`
|
ImageAy int `json:"imageAy"`
|
||||||
ImageBx int `json:"imageBx"`
|
ImageBx int `json:"imageBx"`
|
||||||
ImageBy int `json:"imageBy"`
|
ImageBy int `json:"imageBy"`
|
||||||
|
Banner userimages.CroppedImage `json:"banner"`
|
||||||
HistoryArchiveSupportEnabled bool `json:"historyArchiveSupportEnabled,omitempty"`
|
HistoryArchiveSupportEnabled bool `json:"historyArchiveSupportEnabled,omitempty"`
|
||||||
PinMessageAllMembersEnabled bool `json:"pinMessageAllMembersEnabled,omitempty"`
|
PinMessageAllMembersEnabled bool `json:"pinMessageAllMembersEnabled,omitempty"`
|
||||||
}
|
}
|
||||||
@ -68,9 +69,10 @@ func (c *CreateCommunity) ToCommunityDescription() (*protobuf.CommunityDescripti
|
|||||||
Description: c.Description,
|
Description: c.Description,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Image != "" || c.Banner.ImagePath != "" {
|
||||||
|
ciis := make(map[string]*protobuf.IdentityImage)
|
||||||
if c.Image != "" {
|
if c.Image != "" {
|
||||||
log.Info("has-image", "image", 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)
|
imgs, err := userimages.GenerateIdentityImages(c.Image, c.ImageAx, c.ImageAy, c.ImageBx, c.ImageBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -78,6 +80,15 @@ func (c *CreateCommunity) ToCommunityDescription() (*protobuf.CommunityDescripti
|
|||||||
for _, img := range imgs {
|
for _, img := range imgs {
|
||||||
ciis[img.Name] = adaptIdentityImageToProtobuf(img)
|
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
|
ci.Images = ciis
|
||||||
log.Info("set images", "images", ci)
|
log.Info("set images", "images", ci)
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user