MM-36295: Do not convert thumbnails/previews to jpeg for png images (#21230)

We were applying a white background to transparent images
and converting them to jpegs. This was to make text be legible
behind a black preview background.

However, this has led to a poor user experience, as users rarely
download the full image but always click on previews. Therefore,
we need the previews to remain as pngs.

To fix this, we just re-encode them as pngs instead of jpgs.

```release-note
NONE
```
Этот коммит содержится в:
Agniva De Sarker
2022-10-11 19:04:09 +05:30
коммит произвёл GitHub
родитель 79651874ea
Коммит 279c448da3
14 изменённых файлов: 83 добавлений и 61 удалений

Просмотреть файл

@@ -397,8 +397,8 @@ func TestUploadFiles(t *testing.T) {
{ {
title: "Happy image thumbnail/preview 10", title: "Happy image thumbnail/preview 10",
names: []string{"10000x1.png"}, names: []string{"10000x1.png"},
expectedImageThumbnailNames: []string{"10000x1_expected_thumb.jpeg"}, expectedImageThumbnailNames: []string{"10000x1_expected_thumb.png"},
expectedImagePreviewNames: []string{"10000x1_expected_preview.jpeg"}, expectedImagePreviewNames: []string{"10000x1_expected_preview.png"},
expectImage: true, expectImage: true,
expectedImageWidths: []int{10000}, expectedImageWidths: []int{10000},
expectedImageHeights: []int{1}, expectedImageHeights: []int{1},
@@ -409,8 +409,8 @@ func TestUploadFiles(t *testing.T) {
{ {
title: "Happy image thumbnail/preview 11", title: "Happy image thumbnail/preview 11",
names: []string{"1x10000.png"}, names: []string{"1x10000.png"},
expectedImageThumbnailNames: []string{"1x10000_expected_thumb.jpeg"}, expectedImageThumbnailNames: []string{"1x10000_expected_thumb.png"},
expectedImagePreviewNames: []string{"1x10000_expected_preview.jpeg"}, expectedImagePreviewNames: []string{"1x10000_expected_preview.png"},
expectImage: true, expectImage: true,
expectedImageWidths: []int{1}, expectedImageWidths: []int{1},
expectedImageHeights: []int{10000}, expectedImageHeights: []int{10000},
@@ -678,8 +678,12 @@ func TestUploadFiles(t *testing.T) {
fmt.Sprintf("File %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.Path, expectedPath)) fmt.Sprintf("File %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.Path, expectedPath))
if tc.expectImage { if tc.expectImage {
expectedThumbnailPath := fmt.Sprintf("%s/%s_thumb.jpg", expectedDir, name) // We convert all other image types to jpeg, except pngs.
expectedPreviewPath := fmt.Sprintf("%s/%s_preview.jpg", expectedDir, name) if ext != ".png" {
ext = ".jpg"
}
expectedThumbnailPath := fmt.Sprintf("%s/%s_thumb%s", expectedDir, name, ext)
expectedPreviewPath := fmt.Sprintf("%s/%s_preview%s", expectedDir, name, ext)
assert.Equal(t, dbInfo.ThumbnailPath, expectedThumbnailPath, assert.Equal(t, dbInfo.ThumbnailPath, expectedThumbnailPath,
fmt.Sprintf("Thumbnail for %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.ThumbnailPath, expectedThumbnailPath)) fmt.Sprintf("Thumbnail for %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.ThumbnailPath, expectedThumbnailPath))
assert.Equal(t, dbInfo.PreviewPath, expectedPreviewPath, assert.Equal(t, dbInfo.PreviewPath, expectedPreviewPath,

Просмотреть файл

@@ -266,8 +266,8 @@ func (a *App) getInfoForFilename(post *model.Post, teamID, channelID, userID, ol
if info.IsImage() && !info.IsSvg() { if info.IsImage() && !info.IsSvg() {
nameWithoutExtension := name[:strings.LastIndex(name, ".")] nameWithoutExtension := name[:strings.LastIndex(name, ".")]
info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg" info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview." + getFileExtFromMimeType(info.MimeType)
info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg" info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(info.MimeType)
} }
return info return info
@@ -724,8 +724,8 @@ func (t *UploadFileTask) preprocessImage() *model.AppError {
t.fileinfo.HasPreviewImage = true t.fileinfo.HasPreviewImage = true
nameWithoutExtension := t.Name[:strings.LastIndex(t.Name, ".")] nameWithoutExtension := t.Name[:strings.LastIndex(t.Name, ".")]
t.fileinfo.PreviewPath = t.pathPrefix() + nameWithoutExtension + "_preview.jpg" t.fileinfo.PreviewPath = t.pathPrefix() + nameWithoutExtension + "_preview." + getFileExtFromMimeType(t.fileinfo.MimeType)
t.fileinfo.ThumbnailPath = t.pathPrefix() + nameWithoutExtension + "_thumb.jpg" t.fileinfo.ThumbnailPath = t.pathPrefix() + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(t.fileinfo.MimeType)
// check the image orientation with goexif; consume the bytes we // check the image orientation with goexif; consume the bytes we
// already have first, then keep Tee-ing from input. // already have first, then keep Tee-ing from input.
@@ -770,20 +770,22 @@ func (t *UploadFileTask) postprocessImage(file io.Reader) {
defer release() defer release()
} }
// Fill in the background of a potentially-transparent png file as white
if imgType == "png" {
imaging.FillImageTransparency(decoded, image.White)
}
decoded = imaging.MakeImageUpright(decoded, t.imageOrientation) decoded = imaging.MakeImageUpright(decoded, t.imageOrientation)
if decoded == nil { if decoded == nil {
return return
} }
writeJPEG := func(img image.Image, path string) { writeImage := func(img image.Image, path string) {
r, w := io.Pipe() r, w := io.Pipe()
go func() { go func() {
err := t.imgEncoder.EncodeJPEG(w, img, jpegEncQuality) var err error
// It's okay to access imgType in a separate goroutine,
// because imgType is only written once and never written again.
if imgType == "png" {
err = t.imgEncoder.EncodePNG(w, img)
} else {
err = t.imgEncoder.EncodeJPEG(w, img, jpegEncQuality)
}
if err != nil { if err != nil {
mlog.Error("Unable to encode image as jpeg", mlog.String("path", path), mlog.Err(err)) mlog.Error("Unable to encode image as jpeg", mlog.String("path", path), mlog.Err(err))
w.CloseWithError(err) w.CloseWithError(err)
@@ -804,12 +806,12 @@ func (t *UploadFileTask) postprocessImage(file io.Reader) {
// This is needed on mobile in case of animated GIFs. // This is needed on mobile in case of animated GIFs.
go func() { go func() {
defer wg.Done() defer wg.Done()
writeJPEG(imaging.GenerateThumbnail(decoded, imageThumbnailWidth, imageThumbnailHeight), t.fileinfo.ThumbnailPath) writeImage(imaging.GenerateThumbnail(decoded, imageThumbnailWidth, imageThumbnailHeight), t.fileinfo.ThumbnailPath)
}() }()
go func() { go func() {
defer wg.Done() defer wg.Done()
writeJPEG(imaging.GeneratePreview(decoded, imagePreviewWidth), t.fileinfo.PreviewPath) writeImage(imaging.GeneratePreview(decoded, imagePreviewWidth), t.fileinfo.PreviewPath)
}() }()
go func() { go func() {
@@ -889,8 +891,8 @@ func (a *App) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTe
} }
nameWithoutExtension := filename[:strings.LastIndex(filename, ".")] nameWithoutExtension := filename[:strings.LastIndex(filename, ".")]
info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview.jpg" info.PreviewPath = pathPrefix + nameWithoutExtension + "_preview." + getFileExtFromMimeType(info.MimeType)
info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb.jpg" info.ThumbnailPath = pathPrefix + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(info.MimeType)
} }
if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil { if pluginsEnvironment := a.GetPluginsEnvironment(); pluginsEnvironment != nil {
@@ -949,40 +951,33 @@ func (a *App) HandleImages(previewPathList []string, thumbnailPathList []string,
wg := new(sync.WaitGroup) wg := new(sync.WaitGroup)
for i := range fileData { for i := range fileData {
img, release, err := prepareImage(a.ch.imgDecoder, bytes.NewReader(fileData[i])) img, imgType, release, err := prepareImage(a.ch.imgDecoder, bytes.NewReader(fileData[i]))
if err != nil { if err != nil {
mlog.Debug("Failed to prepare image", mlog.Err(err)) mlog.Debug("Failed to prepare image", mlog.Err(err))
continue continue
} }
wg.Add(2) wg.Add(2)
go func(img image.Image, path string) { go func(img image.Image, imgType, path string) {
defer wg.Done() defer wg.Done()
a.generateThumbnailImage(img, path) a.generateThumbnailImage(img, imgType, path)
}(img, thumbnailPathList[i]) }(img, imgType, thumbnailPathList[i])
go func(img image.Image, path string) { go func(img image.Image, imgType, path string) {
defer wg.Done() defer wg.Done()
a.generatePreviewImage(img, path) a.generatePreviewImage(img, imgType, path)
}(img, previewPathList[i]) }(img, imgType, previewPathList[i])
wg.Wait() wg.Wait()
release() release()
} }
} }
func prepareImage(imgDecoder *imaging.Decoder, imgData io.ReadSeeker) (img image.Image, release func(), err error) { func prepareImage(imgDecoder *imaging.Decoder, imgData io.ReadSeeker) (img image.Image, imgType string, release func(), err error) {
// Decode image bytes into Image object // Decode image bytes into Image object
var imgType string
img, imgType, release, err = imgDecoder.DecodeMemBounded(imgData) img, imgType, release, err = imgDecoder.DecodeMemBounded(imgData)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("prepareImage: failed to decode image: %w", err) return nil, "", nil, fmt.Errorf("prepareImage: failed to decode image: %w", err)
} }
// Fill in the background of a potentially-transparent png file as white
if imgType == "png" {
imaging.FillImageTransparency(img, image.White)
}
imgData.Seek(0, io.SeekStart) imgData.Seek(0, io.SeekStart)
// Flip the image to be upright // Flip the image to be upright
@@ -992,14 +987,23 @@ func prepareImage(imgDecoder *imaging.Decoder, imgData io.ReadSeeker) (img image
} }
img = imaging.MakeImageUpright(img, orientation) img = imaging.MakeImageUpright(img, orientation)
return img, release, nil return img, imgType, release, nil
} }
func (a *App) generateThumbnailImage(img image.Image, thumbnailPath string) { func (a *App) generateThumbnailImage(img image.Image, imgType, thumbnailPath string) {
var buf bytes.Buffer var buf bytes.Buffer
if err := a.ch.imgEncoder.EncodeJPEG(&buf, imaging.GenerateThumbnail(img, imageThumbnailWidth, imageThumbnailHeight), jpegEncQuality); err != nil {
mlog.Error("Unable to encode image as jpeg", mlog.String("path", thumbnailPath), mlog.Err(err)) thumb := imaging.GenerateThumbnail(img, imageThumbnailWidth, imageThumbnailHeight)
return if imgType == "png" {
if err := a.ch.imgEncoder.EncodePNG(&buf, thumb); err != nil {
mlog.Error("Unable to encode image as png", mlog.String("path", thumbnailPath), mlog.Err(err))
return
}
} else {
if err := a.ch.imgEncoder.EncodeJPEG(&buf, thumb, jpegEncQuality); err != nil {
mlog.Error("Unable to encode image as jpeg", mlog.String("path", thumbnailPath), mlog.Err(err))
return
}
} }
if _, err := a.WriteFile(&buf, thumbnailPath); err != nil { if _, err := a.WriteFile(&buf, thumbnailPath); err != nil {
@@ -1008,13 +1012,20 @@ func (a *App) generateThumbnailImage(img image.Image, thumbnailPath string) {
} }
} }
func (a *App) generatePreviewImage(img image.Image, previewPath string) { func (a *App) generatePreviewImage(img image.Image, imgType, previewPath string) {
var buf bytes.Buffer var buf bytes.Buffer
preview := imaging.GeneratePreview(img, imagePreviewWidth)
if err := a.ch.imgEncoder.EncodeJPEG(&buf, preview, jpegEncQuality); err != nil { preview := imaging.GeneratePreview(img, imagePreviewWidth)
mlog.Error("Unable to encode image as preview jpg", mlog.Err(err), mlog.String("path", previewPath)) if imgType == "png" {
return if err := a.ch.imgEncoder.EncodePNG(&buf, preview); err != nil {
mlog.Error("Unable to encode image as preview png", mlog.Err(err), mlog.String("path", previewPath))
return
}
} else {
if err := a.ch.imgEncoder.EncodeJPEG(&buf, preview, jpegEncQuality); err != nil {
mlog.Error("Unable to encode image as preview jpg", mlog.Err(err), mlog.String("path", previewPath))
return
}
} }
if _, err := a.WriteFile(&buf, previewPath); err != nil { if _, err := a.WriteFile(&buf, previewPath); err != nil {
@@ -1033,7 +1044,7 @@ func (a *App) generateMiniPreview(fi *model.FileInfo) {
return return
} }
defer file.Close() defer file.Close()
img, release, err := prepareImage(a.ch.imgDecoder, file) img, _, release, err := prepareImage(a.ch.imgDecoder, file)
if err != nil { if err != nil {
mlog.Debug("generateMiniPreview: prepareImage failed", mlog.Err(err), mlog.Debug("generateMiniPreview: prepareImage failed", mlog.Err(err),
mlog.String("fileinfo_id", fi.Id), mlog.String("channel_id", fi.ChannelId), mlog.String("fileinfo_id", fi.Id), mlog.String("channel_id", fi.ChannelId),
@@ -1409,3 +1420,10 @@ func (a *App) getCloudFilesSizeLimit() (int64, *model.AppError) {
return int64(math.Ceil(float64(*limits.Files.TotalStorage) / 8)), nil return int64(math.Ceil(float64(*limits.Files.TotalStorage) / 8)), nil
} }
func getFileExtFromMimeType(mimeType string) string {
if mimeType == "image/png" {
return "png"
}
return "jpg"
}

Просмотреть файл

@@ -338,7 +338,7 @@ func TestGenerateThumbnailImage(t *testing.T) {
thumbnailPath := filepath.Join(dataPath, thumbnailName) thumbnailPath := filepath.Join(dataPath, thumbnailName)
// when // when
th.App.generateThumbnailImage(img, thumbnailName) th.App.generateThumbnailImage(img, "jpg", thumbnailName)
defer os.Remove(thumbnailPath) defer os.Remove(thumbnailPath)
// then // then

Просмотреть файл

@@ -40,12 +40,12 @@ func (a *App) SlackImport(c *request.Context, fileData multipart.File, fileSize
GeneratePreviewImage: a.generatePreviewImage, GeneratePreviewImage: a.generatePreviewImage,
InvalidateAllCaches: func() { a.ch.srv.InvalidateAllCaches() }, InvalidateAllCaches: func() { a.ch.srv.InvalidateAllCaches() },
MaxPostSize: func() int { return a.ch.srv.platform.MaxPostSize() }, MaxPostSize: func() int { return a.ch.srv.platform.MaxPostSize() },
PrepareImage: func(fileData []byte) (image.Image, func(), error) { PrepareImage: func(fileData []byte) (image.Image, string, func(), error) {
img, release, err := prepareImage(a.ch.imgDecoder, bytes.NewReader(fileData)) img, imgType, release, err := prepareImage(a.ch.imgDecoder, bytes.NewReader(fileData))
if err != nil { if err != nil {
return nil, nil, err return nil, "", nil, err
} }
return img, release, err return img, imgType, release, err
}, },
} }

Просмотреть файл

@@ -298,8 +298,8 @@ func (a *App) UploadData(c *request.Context, us *model.UploadSession, rd io.Read
} }
nameWithoutExtension := info.Name[:strings.LastIndex(info.Name, ".")] nameWithoutExtension := info.Name[:strings.LastIndex(info.Name, ".")]
info.PreviewPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_preview.jpg" info.PreviewPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_preview." + getFileExtFromMimeType(info.MimeType)
info.ThumbnailPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_thumb.jpg" info.ThumbnailPath = filepath.Dir(info.Path) + "/" + nameWithoutExtension + "_thumb." + getFileExtFromMimeType(info.MimeType)
imgData, fileErr := a.ReadFile(uploadPath) imgData, fileErr := a.ReadFile(uploadPath)
if fileErr != nil { if fileErr != nil {
return nil, fileErr return nil, fileErr

Просмотреть файл

@@ -91,11 +91,11 @@ type Actions struct {
CreateGroupChannel func(request.CTX, []string) (*model.Channel, *model.AppError) CreateGroupChannel func(request.CTX, []string) (*model.Channel, *model.AppError)
CreateChannel func(*model.Channel, bool) (*model.Channel, *model.AppError) CreateChannel func(*model.Channel, bool) (*model.Channel, *model.AppError)
DoUploadFile func(time.Time, string, string, string, string, []byte) (*model.FileInfo, *model.AppError) DoUploadFile func(time.Time, string, string, string, string, []byte) (*model.FileInfo, *model.AppError)
GenerateThumbnailImage func(image.Image, string) GenerateThumbnailImage func(image.Image, string, string)
GeneratePreviewImage func(image.Image, string) GeneratePreviewImage func(image.Image, string, string)
InvalidateAllCaches func() InvalidateAllCaches func()
MaxPostSize func() int MaxPostSize func() int
PrepareImage func(fileData []byte) (image.Image, func(), error) PrepareImage func(fileData []byte) (image.Image, string, func(), error)
} }
// SlackImporter is a service that allows to import slack dumps into mattermost // SlackImporter is a service that allows to import slack dumps into mattermost
@@ -793,13 +793,13 @@ func (si *SlackImporter) oldImportFile(timestamp time.Time, file io.Reader, team
} }
if fileInfo.IsImage() && !fileInfo.IsSvg() { if fileInfo.IsImage() && !fileInfo.IsSvg() {
img, release, err := si.actions.PrepareImage(data) img, imgType, release, err := si.actions.PrepareImage(data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer release() defer release()
si.actions.GenerateThumbnailImage(img, fileInfo.ThumbnailPath) si.actions.GenerateThumbnailImage(img, imgType, fileInfo.ThumbnailPath)
si.actions.GeneratePreviewImage(img, fileInfo.PreviewPath) si.actions.GeneratePreviewImage(img, imgType, fileInfo.PreviewPath)
} }
return fileInfo, nil return fileInfo, nil

Двоичные данные
tests/10000x1_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 1.1 KiB

Двоичные данные
tests/10000x1_expected_preview.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 92 B

Двоичные данные
tests/10000x1_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 628 B

Двоичные данные
tests/10000x1_expected_thumb.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 76 B

Двоичные данные
tests/1x10000_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 3.0 KiB

Двоичные данные
tests/1x10000_expected_preview.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 121 B

Двоичные данные
tests/1x10000_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 624 B

Двоичные данные
tests/1x10000_expected_thumb.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 79 B