Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Imagemagickを抹殺 #2143

Merged
merged 41 commits into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
955bc1b
reimplement gif resize func with pure go
logica0419 Dec 7, 2023
841930d
delete imagemagick utils
logica0419 Dec 7, 2023
8ab4114
using ErrInvalidImageSrc
logica0419 Dec 7, 2023
6e61063
remove redundant error
logica0419 Dec 7, 2023
cbcab23
delete imagemagick path from every config
logica0419 Dec 7, 2023
4b6339b
return error in convert util
logica0419 Dec 8, 2023
afc37e2
make variable names understandable
logica0419 Dec 9, 2023
eba7e4f
fix render gif
logica0419 Dec 9, 2023
b6fae9b
fix bound calculation
logica0419 Dec 9, 2023
fd2f92f
rename vars
logica0419 Dec 9, 2023
250cf6e
using imaging package
logica0419 Dec 9, 2023
0daa012
round coordinates
logica0419 Dec 10, 2023
e9ddab2
resize composite frame
logica0419 Dec 10, 2023
74ab087
write comments
logica0419 Dec 10, 2023
c925749
initialize Image slice
logica0419 Dec 10, 2023
719a37e
delete imagemagick from Dockerfile
logica0419 Dec 10, 2023
e907dbf
parallelize animated gif resize
logica0419 Dec 10, 2023
2185fc8
power up concurrency
logica0419 Dec 10, 2023
8b1431d
put out type definition
logica0419 Dec 10, 2023
0c8d8b0
rename package alias
logica0419 Dec 10, 2023
78f085a
prepare IoReaderToBytes util
logica0419 Dec 10, 2023
33ddae6
add gif testdata
logica0419 Dec 10, 2023
708fc5f
write test for FitAnimationGIF
logica0419 Dec 10, 2023
d13c886
add testcases
logica0419 Dec 10, 2023
3471bbf
changed implementation
logica0419 Dec 10, 2023
113444a
moved MustOpenGif to testutils
logica0419 Dec 10, 2023
9e684a5
write test for GifToBytesReader
logica0419 Dec 10, 2023
cebe6bd
parallelize test
logica0419 Dec 10, 2023
4b7a666
fix test naming
logica0419 Dec 10, 2023
662751b
Revert "power up concurrency"
logica0419 Dec 10, 2023
4302fa8
fix concurrency limit setting
logica0419 Dec 10, 2023
257fa13
not testing concurrency pattern
logica0419 Dec 11, 2023
6cb6c02
support disposal method
logica0419 Dec 11, 2023
3f0350d
using only free images in testing
logica0419 Dec 11, 2023
3f2c8d9
replace buggy image
logica0419 Dec 11, 2023
80932b2
replace names
logica0419 Dec 11, 2023
5e061b4
fix test file names & files
logica0419 Dec 11, 2023
fa38f02
refactor resize goroutine starting
logica0419 Dec 11, 2023
53cc3d9
use deep copy to avoid
logica0419 Dec 11, 2023
80ec7b5
moved type definition readability
logica0419 Dec 11, 2023
95c6d03
use io.ReadAll & lo.Must
logica0419 Dec 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/tmp/go/cach
FROM alpine:3.19.0
WORKDIR /app

RUN apk add --no-cache --update ca-certificates imagemagick && \
update-ca-certificates
ENV TRAQ_IMAGEMAGICK=/usr/bin/convert
RUN apk add --no-cache --update ca-certificates && update-ca-certificates

COPY --from=build /traQ ./

Expand Down
5 changes: 0 additions & 5 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,6 @@ type Config struct {
Enabled bool `mapstructure:"enabled" yaml:"enabled"`
} `mapstructure:"accessLog" yaml:"accessLog"`

// ImageMagick ImageMagick実行ファイルパス
ImageMagick string `mapstructure:"imagemagick" yaml:"imagemagick"`

// Imaging 画像処理設定
Imaging struct {
// MaxPixels 処理可能な最大画素数 (default: 2560*1600)
Expand Down Expand Up @@ -263,7 +260,6 @@ func init() {
viper.SetDefault("gzip", true)
viper.SetDefault("allowSignUp", false)
viper.SetDefault("accessLog.enabled", true)
viper.SetDefault("imagemagick", "")
viper.SetDefault("imaging.maxPixels", 2560*1600)
viper.SetDefault("imaging.concurrency", 1)
viper.SetDefault("mariadb.host", "127.0.0.1")
Expand Down Expand Up @@ -470,7 +466,6 @@ func provideImageProcessorConfig(c *Config) imaging.Config {
MaxPixels: c.Imaging.MaxPixels,
Concurrency: c.Imaging.Concurrency,
ThumbnailMaxSize: image.Pt(360, 480),
ImageMagickPath: c.ImageMagick,
}
}

Expand Down
20 changes: 8 additions & 12 deletions router/utils/process_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import (
"github.com/traPtitech/traQ/router/consts"
"github.com/traPtitech/traQ/router/extension/herror"
"github.com/traPtitech/traQ/service/file"
imaging2 "github.com/traPtitech/traQ/service/imaging"
"github.com/traPtitech/traQ/utils/imaging"
"github.com/traPtitech/traQ/service/imaging"
)

const (
Expand All @@ -24,16 +23,16 @@ const (
)

// SaveUploadIconImage MultipartFormでアップロードされたアイコン画像ファイルを保存
func SaveUploadIconImage(p imaging2.Processor, c echo.Context, m file.Manager, name string) (uuid.UUID, error) {
func SaveUploadIconImage(p imaging.Processor, c echo.Context, m file.Manager, name string) (uuid.UUID, error) {
return saveUploadImage(p, c, m, name, model.FileTypeIcon, iconMaxFileSize, iconMaxImageSize)
}

// SaveUploadStampImage MultipartFormでアップロードされたスタンプ画像ファイルを保存
func SaveUploadStampImage(p imaging2.Processor, c echo.Context, m file.Manager, name string) (uuid.UUID, error) {
func SaveUploadStampImage(p imaging.Processor, c echo.Context, m file.Manager, name string) (uuid.UUID, error) {
return saveUploadImage(p, c, m, name, model.FileTypeStamp, stampMaxFileSize, stampMaxImageSize)
}

func saveUploadImage(p imaging2.Processor, c echo.Context, m file.Manager, name string, fType model.FileType, maxFileSize int64, maxImageSize int) (uuid.UUID, error) {
func saveUploadImage(p imaging.Processor, c echo.Context, m file.Manager, name string, fType model.FileType, maxFileSize int64, maxImageSize int) (uuid.UUID, error) {
const (
tooLargeImage = "too large image"
badImage = "bad image"
Expand Down Expand Up @@ -61,17 +60,17 @@ func saveUploadImage(p imaging2.Processor, c echo.Context, m file.Manager, name
img, err := p.Fit(src, maxImageSize, maxImageSize)
if err != nil {
switch err {
case imaging2.ErrInvalidImageSrc:
case imaging.ErrInvalidImageSrc:
return uuid.Nil, herror.BadRequest(badImage)
case imaging2.ErrPixelLimitExceeded:
case imaging.ErrPixelLimitExceeded:
return uuid.Nil, herror.BadRequest(tooLargeImage)
default:
return uuid.Nil, herror.InternalServerError(err)
}
}

// PNGに変換
var b = bytes.Buffer{}
b := bytes.Buffer{}
if err := png.Encode(&b, img); err != nil {
return uuid.Nil, herror.InternalServerError(err)
}
Expand All @@ -86,10 +85,7 @@ func saveUploadImage(p imaging2.Processor, c echo.Context, m file.Manager, name
b, err := p.FitAnimationGIF(src, maxImageSize, maxImageSize)
if err != nil {
switch err {
case imaging.ErrImageMagickUnavailable:
// gifは一時的にサポートされていない
return uuid.Nil, herror.BadRequest("gif file is temporarily unsupported")
case imaging2.ErrInvalidImageSrc, imaging2.ErrTimeout:
case imaging.ErrInvalidImageSrc:
// 不正なgifである
return uuid.Nil, herror.BadRequest(badImage)
default:
Expand Down
1 change: 0 additions & 1 deletion router/v1/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ func TestMain(m *testing.M) {
MaxPixels: 1000 * 1000,
Concurrency: 1,
ThumbnailMaxSize: image.Pt(360, 480),
ImageMagickPath: "",
})
env.FileManager, _ = file.InitFileManager(env.Repository, storage.NewInMemoryFileStorage(), env.ImageProcessor, zap.NewNop())

Expand Down
1 change: 0 additions & 1 deletion router/v3/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ func TestMain(m *testing.M) {
MaxPixels: 1000 * 1000,
Concurrency: 1,
ThumbnailMaxSize: image.Pt(360, 480),
ImageMagickPath: "",
})
env.FM, _ = file.InitFileManager(repo, storage.NewInMemoryFileStorage(), env.IP, l.Named("FM"))

Expand Down
3 changes: 0 additions & 3 deletions service/imaging/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
var (
ErrPixelLimitExceeded = errors.New("the image exceeds max pixels limit")
ErrInvalidImageSrc = errors.New("invalid image src")
ErrTimeout = errors.New("processing timeout")
)

type Config struct {
Expand All @@ -19,6 +18,4 @@ type Config struct {
Concurrency int
// ThumbnailMaxSize サムネイル画像サイズ
ThumbnailMaxSize image.Point
// ImageMagickPath imagemagickの実行パス
ImageMagickPath string
}
158 changes: 145 additions & 13 deletions service/imaging/processor_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@ import (
"context"
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
_ "image/jpeg" // image.Decode用
_ "image/png" // image.Decode用
"io"
"time"
"math"
"sync"

_ "golang.org/x/image/webp" // image.Decode用

"github.com/disintegration/imaging"
"github.com/go-audio/wav"
"github.com/hajimehoshi/go-mp3"
"github.com/motoki317/go-waveform"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"

imaging2 "github.com/traPtitech/traQ/utils/imaging"
Expand Down Expand Up @@ -73,21 +78,148 @@ func (p *defaultProcessor) Fit(src io.ReadSeeker, width, height int) (image.Imag
}

func (p *defaultProcessor) FitAnimationGIF(src io.Reader, width, height int) (*bytes.Reader, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // 10秒以内に終わらないファイルは無効
defer cancel()

b, err := imaging2.ResizeAnimationGIF(ctx, p.c.ImageMagickPath, src, width, height, false)
srcImage, err := gif.DecodeAll(src)
if err != nil {
switch err {
case context.DeadlineExceeded:
return nil, ErrTimeout
case imaging2.ErrInvalidImageSrc:
return nil, ErrInvalidImageSrc
default:
return nil, err
return nil, ErrInvalidImageSrc
}

srcWidth, srcHeight := srcImage.Config.Width, srcImage.Config.Height
// 画素数チェック
if srcWidth*srcHeight > p.c.MaxPixels {
return nil, ErrPixelLimitExceeded
}
// 画像が十分小さければスキップ
if srcWidth <= width && srcHeight <= height {
return imaging2.GifToBytesReader(srcImage)
}

// 元の比率を保つよう調整 & 拡大・縮小比率を計算
floatSrcWidth, floatSrcHeight, floatWidth, floatHeight := float64(srcWidth), float64(srcHeight), float64(width), float64(height)
ratio := floatWidth / floatSrcWidth
if floatSrcWidth/floatSrcHeight > floatWidth/floatHeight {
ratio = floatWidth / floatSrcWidth
height = int(math.Round(floatSrcHeight * ratio))
} else if floatSrcWidth/floatSrcHeight < floatWidth/floatHeight {
ratio = floatHeight / floatSrcHeight
width = int(math.Round(floatSrcWidth * ratio))
}

destImage := &gif.GIF{
Image: make([]*image.Paletted, len(srcImage.Image)),
Delay: srcImage.Delay,
LoopCount: srcImage.LoopCount,
Disposal: srcImage.Disposal,
Config: image.Config{
ColorModel: srcImage.Config.ColorModel,
Width: width,
Height: height,
},
BackgroundIndex: srcImage.BackgroundIndex,
}

var (
gifBound = image.Rect(0, 0, srcWidth, srcHeight)

// フレームを重ねるためのキャンバス
// 差分最適化されたGIFに対応するための処置
// 差分最適化されたGIFでは、1フレーム目以外、周りが透明ピクセルのフレームを
// 次々に重ねていくことでアニメーションを表現する
// 周りが透明ピクセルのフレームをそのまま縮小すると、周りの透明ピクセルと
// 混ざった色が透明色ではなくなってフレームの縁に黒っぽいノイズが入ってしまう
// ため、キャンバスでフレームを重ねてから縮小する
tempCanvas = image.NewNRGBA(gifBound)
// DisposalPreviousに対応するため、Disposeされていないフレームを保持
unDisposedFrame = image.NewNRGBA(gifBound)

// destImage.ImageのためのMutex
destImageMutex = &sync.Mutex{}
eg, _ = errgroup.WithContext(context.Background())
)

for i, srcFrame := range srcImage.Image {
// 元のフレームのサイズと位置
// 差分最適化されたGIFでは、これが元GIFのサイズより小さいことがある
srcBounds := srcFrame.Bounds()
// 縮小後のフレームのサイズと位置を計算
destBounds := image.Rect(
int(math.Round(float64(srcBounds.Min.X)*ratio)),
int(math.Round(float64(srcBounds.Min.Y)*ratio)),
int(math.Round(float64(srcBounds.Max.X)*ratio)),
int(math.Round(float64(srcBounds.Max.Y)*ratio)),
)

switch srcImage.Disposal[i] {
case gif.DisposalBackground:
// Disposalが2に設定されていたらキャンバスを初期化
tempCanvas = image.NewNRGBA(gifBound)

case gif.DisposalPrevious:
// Disposalが3に設定されていたら、Disposeされていないフレームまでキャンバスを戻す
tempCanvas = unDisposedFrame
}

// キャンバスに読んだフレームを重ねる
draw.Draw(tempCanvas, srcBounds, srcFrame, srcBounds.Min, draw.Over)

// Disposalが1に設定されていたら、Disposeされていないフレームを更新
if srcImage.Disposal[i] == gif.DisposalNone {
unDisposedFrame = &image.NRGBA{
Pix: append([]uint8{}, tempCanvas.Pix...),
Stride: tempCanvas.Stride,
Rect: tempCanvas.Rect,
} // tempCanvasはポインタを使い回しているので、Deep Copyする
}

// 拡縮用GoRoutineを起動
eg.Go(resizeRoutine(frameData{
index: i,
tempCanvas: &image.NRGBA{
Pix: append([]uint8{}, tempCanvas.Pix...),
Stride: tempCanvas.Stride,
Rect: tempCanvas.Rect,
}, // tempCanvasはポインタを使い回しているので、Deep Copyする
resizeWidth: width,
resizeHeight: height,
srcBounds: srcBounds,
destBounds: destBounds,
srcPalette: srcImage.Image[i].Palette,
}, destImage, destImageMutex))
}

err = eg.Wait()
if err != nil {
return nil, err
}

return imaging2.GifToBytesReader(destImage)
}

// GIFのリサイズ時、拡縮用GoRoutineに渡すフレームのデータ
type frameData struct {
index int
tempCanvas *image.NRGBA
resizeWidth int
resizeHeight int
srcBounds image.Rectangle
destBounds image.Rectangle
srcPalette color.Palette
}

func resizeRoutine(data frameData, destImage *gif.GIF, destImageMutex *sync.Mutex) func() error {
return func() error {
// 重ねたフレームを縮小
fittedImage := imaging.Resize(data.tempCanvas, data.resizeWidth, data.resizeHeight, mks2013Filter)

// destBoundsに合わせて、縮小されたイメージを切り抜き
destFrame := image.NewPaletted(data.destBounds, data.srcPalette)
draw.Draw(destFrame, data.destBounds, fittedImage.SubImage(data.destBounds), data.destBounds.Min, draw.Src)

destImageMutex.Lock()
defer destImageMutex.Unlock()
destImage.Image[data.index] = destFrame

return nil
}
return b, nil
}

func (p *defaultProcessor) WaveformMp3(src io.ReadSeeker, width, height int) (r io.Reader, err error) {
Expand Down
Loading
Loading