Skip to content

Commit adc2e16

Browse files
authored
Merge pull request #2 from syntaxsdev/feat/use-bimg-for-speed
feat: swap native Go imaging for C libs for 5x faster processing + parallel thumbnails
2 parents 13447b1 + 51de2c1 commit adc2e16

File tree

5 files changed

+104
-71
lines changed

5 files changed

+104
-71
lines changed

Dockerfile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
WORKDIR /app
44

5-
# Install build dependencies (including libwebp for CGO)
6-
RUN apk add --no-cache build-base libwebp-dev
5+
# Install build dependencies (including libwebp and vips for CGO)
6+
RUN apk add --no-cache build-base libwebp-dev vips-dev pkgconfig
77

88
COPY go.mod go.sum ./
99
RUN go mod download
@@ -15,9 +15,9 @@
1515

1616
FROM alpine:latest
1717

18-
# Install ca-certificates and libwebp (with fallback for ARM64)
18+
# Install ca-certificates, libwebp and vips runtime (with fallback for ARM64)
1919
RUN apk --no-cache add ca-certificates && \
20-
(apk add --no-cache libwebp || echo "libwebp not available for this platform")
20+
(apk add --no-cache libwebp vips || echo "libwebp/vips not available for this platform")
2121

2222
# Create a non-root user
2323
RUN addgroup -g 1001 -S appgroup && \

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ run-air:
1212
@echo "Starting server with air 🚀"
1313
@set -a && . ./.env && air
1414

15+
run-air-local:
16+
@echo "Starting server with air 🚀"
17+
@set -a && . ./.env.local && air
1518

1619
build:
1720
@echo "Building server 🔨"

go.mod

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ require (
77
github.com/aws/aws-sdk-go-v2/config v1.30.2
88
github.com/aws/aws-sdk-go-v2/credentials v1.18.2
99
github.com/aws/aws-sdk-go-v2/service/s3 v1.85.1
10-
github.com/disintegration/imaging v1.6.2
10+
gopkg.in/h2non/bimg.v1 v1.1.9
11+
gopkg.in/yaml.v3 v3.0.1
1112
)
1213

1314
require (
@@ -25,7 +26,4 @@ require (
2526
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.31.1 // indirect
2627
github.com/aws/aws-sdk-go-v2/service/sts v1.35.1 // indirect
2728
github.com/aws/smithy-go v1.22.5 // indirect
28-
github.com/chai2010/webp v1.4.0 // indirect
29-
golang.org/x/image v0.29.0 // indirect
30-
gopkg.in/yaml.v3 v3.0.1 // indirect
3129
)

go.sum

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,9 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.35.1 h1:iF4Xxkc0H9c/K2dS0zZw3SCkj0Z7
3434
github.com/aws/aws-sdk-go-v2/service/sts v1.35.1/go.mod h1:0bxIatfN0aLq4mjoLDeBpOjOke68OsFlXPDFJ7V0MYw=
3535
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
3636
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
37-
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
38-
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
39-
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
40-
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
41-
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
42-
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
43-
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
44-
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
45-
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
37+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
4638
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
39+
gopkg.in/h2non/bimg.v1 v1.1.9 h1:wZIUbeOnwr37Ta4aofhIv8OI8v4ujpjXC9mXnAGpQjM=
40+
gopkg.in/h2non/bimg.v1 v1.1.9/go.mod h1:PgsZL7dLwUbsGm1NYps320GxGgvQNTnecMCZqxV11So=
4741
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
4842
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/service/image.go

Lines changed: 92 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,17 @@ import (
44
"bytes"
55
"context"
66
"fmt"
7-
"image"
8-
"image/jpeg"
9-
"image/png"
107
"io"
118
"mime/multipart"
129
"net/http"
1310
"path/filepath"
1411
"strconv"
1512
"strings"
1613

17-
"github.com/chai2010/webp"
14+
"gopkg.in/h2non/bimg.v1"
1815

1916
"mediaflow/internal/config"
2017
"mediaflow/internal/s3"
21-
22-
"github.com/disintegration/imaging"
2318
)
2419

2520
type ImageService struct {
@@ -49,71 +44,114 @@ func NewImageService(cfg *config.Config) *ImageService {
4944
func (s *ImageService) UploadImage(ctx context.Context, so *config.StorageOptions, imageData []byte, thumbType, imagePath string) error {
5045
orig_path := fmt.Sprintf("%s/%s", so.OriginFolder, imagePath)
5146
convertType := so.ConvertTo
52-
// Upload original image
53-
err := s.S3Client.PutObject(ctx, orig_path, bytes.NewReader(imageData))
54-
if err != nil {
55-
return fmt.Errorf("failed to upload original image to S3: %w", err)
56-
}
57-
58-
// Generate and upload thumbnails for each size
59-
for _, sizeStr := range so.Sizes {
60-
size, err := strconv.Atoi(sizeStr)
47+
48+
// Upload original image in parallel with thumbnail generation
49+
origUploadChan := make(chan error, 1)
50+
go func() {
51+
err := s.S3Client.PutObject(ctx, orig_path, bytes.NewReader(imageData))
6152
if err != nil {
62-
return fmt.Errorf("invalid size format: %s", sizeStr)
53+
origUploadChan <- fmt.Errorf("failed to upload original image to S3: %w", err)
54+
} else {
55+
origUploadChan <- nil
6356
}
57+
}()
58+
59+
// Generate and upload thumbnails in parallel
60+
type thumbnailJob struct {
61+
sizeStr string
62+
data []byte
63+
path string
64+
err error
65+
}
66+
67+
thumbJobs := make(chan thumbnailJob, len(so.Sizes))
68+
uploadErrors := make(chan error, len(so.Sizes))
69+
70+
// Generate thumbnails in parallel
71+
for _, sizeStr := range so.Sizes {
72+
go func(size string) {
73+
sizeInt, err := strconv.Atoi(size)
74+
if err != nil {
75+
thumbJobs <- thumbnailJob{sizeStr: size, err: fmt.Errorf("invalid size format: %s", size)}
76+
return
77+
}
6478

65-
// Generate thumbnail
66-
thumbnailData, err := s.generateThumbnail(imageData, size, so.Quality, convertType)
67-
if err != nil {
68-
return fmt.Errorf("failed to generate thumbnail for size %d: %w", size, err)
69-
}
79+
thumbnailData, err := s.generateThumbnail(imageData, sizeInt, so.Quality, convertType)
80+
if err != nil {
81+
thumbJobs <- thumbnailJob{sizeStr: size, err: fmt.Errorf("failed to generate thumbnail for size %s: %w", size, err)}
82+
return
83+
}
7084

71-
// Create thumbnail path with size suffix
72-
thumbSizePath := s.createThumbnailPathForSize(imagePath, sizeStr, convertType)
73-
thumbFullPath := fmt.Sprintf("%s/%s", so.ThumbFolder, thumbSizePath)
85+
thumbSizePath := s.createThumbnailPathForSize(imagePath, size, convertType)
86+
thumbFullPath := fmt.Sprintf("%s/%s", so.ThumbFolder, thumbSizePath)
87+
88+
thumbJobs <- thumbnailJob{
89+
sizeStr: size,
90+
data: thumbnailData,
91+
path: thumbFullPath,
92+
err: nil,
93+
}
94+
}(sizeStr)
95+
}
7496

75-
// Upload thumbnail
76-
err = s.S3Client.PutObject(ctx, thumbFullPath, bytes.NewReader(thumbnailData))
77-
if err != nil {
78-
return fmt.Errorf("failed to upload thumbnail for size %d: %w", size, err)
97+
// Upload thumbnails in parallel as they're generated
98+
for i := 0; i < len(so.Sizes); i++ {
99+
go func() {
100+
job := <-thumbJobs
101+
if job.err != nil {
102+
uploadErrors <- job.err
103+
return
104+
}
105+
106+
err := s.S3Client.PutObject(ctx, job.path, bytes.NewReader(job.data))
107+
if err != nil {
108+
uploadErrors <- fmt.Errorf("failed to upload thumbnail for size %s: %w", job.sizeStr, err)
109+
} else {
110+
uploadErrors <- nil
111+
}
112+
}()
113+
}
114+
115+
// Wait for original upload
116+
if err := <-origUploadChan; err != nil {
117+
return err
118+
}
119+
120+
// Wait for all thumbnail uploads
121+
for i := 0; i < len(so.Sizes); i++ {
122+
if err := <-uploadErrors; err != nil {
123+
return err
79124
}
80125
}
81126

82127
return nil
83128
}
84129

85130
func (s *ImageService) generateThumbnail(imageData []byte, width, quality int, convertTo string) ([]byte, error) {
86-
// Decode the original image
87-
img, _, err := image.Decode(bytes.NewReader(imageData))
88-
if err != nil {
89-
return nil, fmt.Errorf("failed to decode image: %w", err)
131+
options := bimg.Options{
132+
Width: width,
133+
Quality: quality,
90134
}
91-
92-
resizedImg := imaging.Resize(img, width, 0, imaging.Lanczos)
93-
94-
// Encode the resized image
95-
var buf bytes.Buffer
96-
97-
// Determine content type and encode accordingly
98-
if strings.Contains(convertTo, "jpeg") || strings.Contains(convertTo, "jpg") {
99-
opts := &jpeg.Options{Quality: quality}
100-
err = jpeg.Encode(&buf, resizedImg, opts)
101-
} else if strings.Contains(convertTo, "png") {
102-
err = png.Encode(&buf, resizedImg)
103-
} else if strings.Contains(convertTo, "webp") {
104-
opts := &webp.Options{Quality: float32(quality)}
105-
err = webp.Encode(&buf, resizedImg, opts)
106-
} else {
135+
136+
// Set output format
137+
switch convertTo {
138+
case "webp":
139+
options.Type = bimg.WEBP
140+
case "jpeg", "jpg":
141+
options.Type = bimg.JPEG
142+
case "png":
143+
options.Type = bimg.PNG
144+
default:
107145
// Default to JPEG if format is unknown (fallback)
108-
opts := &jpeg.Options{Quality: quality}
109-
err = jpeg.Encode(&buf, resizedImg, opts)
146+
options.Type = bimg.JPEG
110147
}
111-
148+
149+
resizedData, err := bimg.NewImage(imageData).Process(options)
112150
if err != nil {
113-
return nil, fmt.Errorf("failed to encode thumbnail: %w", err)
151+
return nil, fmt.Errorf("failed to process image with bimg: %w", err)
114152
}
115-
116-
return buf.Bytes(), nil
153+
154+
return resizedData, nil
117155
}
118156

119157
func (s *ImageService) createThumbnailPathForSize(originalPath, size, newType string) string {

0 commit comments

Comments
 (0)