Skip to content

Commit 8dc2ee7

Browse files
authored
Merge pull request #41 from Pixboost/feature/jxl
Feature/jxl
2 parents 6096d7b + ff267f6 commit 8dc2ee7

14 files changed

Lines changed: 461 additions & 337 deletions

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM dpokidov/imagemagick:7.1.1-10-bullseye AS build
1+
FROM dpokidov/imagemagick:7.1.1-17-bullseye AS build
22

33
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
44
g++ \
@@ -134,7 +134,7 @@ WORKDIR /go/src/github.com/Pixboost/transformimgs/cmd
134134

135135
RUN go build -o /transformimgs
136136

137-
FROM dpokidov/imagemagick:7.1.1-10-bullseye
137+
FROM dpokidov/imagemagick:7.1.1-17-bullseye
138138

139139
ENV IM_HOME /usr/local/bin
140140

Dockerfile.dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM dpokidov/imagemagick:7.1.1-10-bullseye
1+
FROM dpokidov/imagemagick:7.1.1-17-bullseye
22

33
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
44
g++ \

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
[![Docker Automated build](https://img.shields.io/docker/automated/jrottenberg/ffmpeg.svg)](https://hub.docker.com/r/pixboost/transformimgs/)
1111

1212
Open Source [Image CDN](https://web.dev/image-cdns/) that provides image transformation API and supports
13-
the latest image formats, such as WebP, AVIF and network client hints.
13+
the latest image formats, such as WebP, AVIF, Jpeg XL, and network client hints.
1414

1515

1616
## Table of Contents
@@ -157,6 +157,11 @@ $ jmeter -n -t perf-test-webp.jmx -l ./results-webp.jmx -e -o ./results-webp
157157
$ jmeter -n -t perf-test-avif.jmx -l ./results-avif.jmx -e -o ./results-avif
158158
```
159159

160+
* Run JMeter JPEG XL test:
161+
```
162+
$ jmeter -n -t perf-test-jxl.jmx -l ./results-jxl.jmx -e -o ./results-jxl
163+
```
164+
160165

161166
## Opened tickets for images related features
162167

img/processor/imagemagick.go

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,14 @@ const (
6060
// MaxAVIFTargetSize is a maximum size in pixels of the result image
6161
// that could be converted to AVIF.
6262
//
63-
// There are two aspects to this:
64-
// * Encoding to AVIF consumes a lot of memory
65-
// * On big sizes quality of Webp is better (could be a codec thing rather than a format)
63+
// This is mainly done because encoding to AVIF consumes a lot of memory, and CPU time
6664
MaxAVIFTargetSize = 2000 * 2000
65+
66+
MaxJxlLossyTargetSize = 1000 * 1000
67+
68+
JxlMime = "image/jxl"
69+
WebpMime = "image/webp"
70+
AvifMime = "image/avif"
6771
)
6872

6973
func init() {
@@ -449,22 +453,29 @@ func (p *ImageMagick) isIllustration(src *img.Image, info *img.Info) (bool, erro
449453
func getOutputFormat(src *img.Info, target *img.Info, supportedFormats []string) (string, string) {
450454
webP := false
451455
avif := false
456+
jxl := false
452457
for _, f := range supportedFormats {
453-
if f == "image/webp" && src.Height < MaxWebpHeight && src.Width < MaxWebpWidth {
458+
if f == WebpMime && src.Height < MaxWebpHeight && src.Width < MaxWebpWidth {
454459
webP = true
455460
}
456461

457462
targetSize := target.Width * target.Height
458-
if f == "image/avif" && src.Format != "GIF" && !src.Illustration && targetSize < MaxAVIFTargetSize && targetSize != 0 {
463+
if f == AvifMime && src.Format != "GIF" && targetSize < MaxAVIFTargetSize && targetSize != 0 {
459464
avif = true
460465
}
461-
}
462466

463-
if avif {
464-
return "avif:-", "image/avif"
467+
if f == JxlMime && src.Format != "GIF" && (src.Illustration || targetSize < MaxJxlLossyTargetSize) {
468+
jxl = true
469+
}
465470
}
466-
if webP {
467-
return "webp:-", "image/webp"
471+
472+
switch {
473+
case (src.Illustration && jxl) || (jxl && !avif):
474+
return "jxl:-", JxlMime
475+
case avif && !src.Illustration:
476+
return "avif:-", AvifMime
477+
case webP:
478+
return "webp:-", WebpMime
468479
}
469480

470481
return "-", ""
@@ -473,7 +484,9 @@ func getOutputFormat(src *img.Info, target *img.Info, supportedFormats []string)
473484
func getConvertFormatOptions(source *img.Info) []string {
474485
var opts []string
475486
if source.Illustration {
476-
opts = append(opts, "-define", "webp:lossless=true")
487+
opts = append(opts, "-define", "webp:lossless=true", "-quality", "100", "-define", "jxl:effort=9")
488+
} else {
489+
opts = append(opts, "-define", "jxl:effort=7")
477490
}
478491
if source.Format != "GIF" {
479492
opts = append(opts, "-define", "webp:method=6")
@@ -497,23 +510,40 @@ func getQualityOptions(source *img.Info, config *img.TransformationConfig, outpu
497510

498511
img.Log.Printf("[%s] Getting quality for the image, source quality: %d, quality: %d, output type: %s", config.Src.Id, source.Quality, config.Quality, outputMimeType)
499512

500-
if outputMimeType == "image/avif" {
501-
if source.Quality > 85 {
513+
if source.Illustration {
514+
return []string{}
515+
}
516+
517+
switch {
518+
case outputMimeType == AvifMime:
519+
switch {
520+
case source.Quality > 85:
502521
quality = 70
503-
} else if source.Quality > 75 {
522+
case source.Quality > 75:
504523
quality = 60
505-
} else {
524+
default:
506525
quality = 50
507526
}
508-
} else if source.Quality == 100 {
527+
case outputMimeType == JxlMime:
528+
switch {
529+
case source.Quality > 85:
530+
quality = 82
531+
case source.Quality > 75:
532+
quality = 72
533+
default:
534+
quality = 62
535+
}
536+
case source.Quality == 100:
509537
quality = 82
510-
} else if config.Quality != img.DEFAULT {
538+
case config.Quality != img.DEFAULT:
511539
quality = source.Quality
512540
}
513541

514542
if quality == 0 {
515543
return []string{}
516544
}
545+
546+
// If using lossy compression, then we can go lower
517547
if quality != 100 {
518548
switch config.Quality {
519549
case img.LOW:

img/processor/imagemagick_test.go

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ func BenchmarkImageMagickProcessor_Optimise_Avif(b *testing.B) {
6666
benchmarkWithFormats(b, []string{"image/avif"})
6767
}
6868

69+
func BenchmarkImageMagickProcessor_Optimise_Jxl(b *testing.B) {
70+
benchmarkWithFormats(b, []string{"image/jxl"})
71+
}
72+
6973
func benchmarkWithFormats(b *testing.B, formats []string) {
7074
f := fmt.Sprintf("%s/%s", "./test_files/transformations", "medium-jpeg.jpg")
7175

@@ -319,6 +323,29 @@ func TestImageMagickProcessor_Optimise_Avif(t *testing.T) {
319323
})
320324
}
321325

326+
func TestImageMagickProcessor_Optimise_Jxl(t *testing.T) {
327+
testImages(t, func(orig []byte, imgId string) (*img.Image, error) {
328+
return proc.Optimise(&img.TransformationConfig{
329+
Src: &img.Image{
330+
Id: imgId,
331+
Data: orig,
332+
},
333+
SupportedFormats: []string{"image/jxl"},
334+
})
335+
},
336+
[]*testTransformation{
337+
{"big-jpeg.jpg", ""},
338+
{"medium-jpeg.jpg", "image/jxl"},
339+
{"opaque-png.png", "image/jxl"},
340+
{"animated.gif", ""},
341+
{"animated-coalesce.gif", ""},
342+
{"transparent-png.png", "image/jxl"},
343+
{"small-transparent-png.png", "image/jxl"},
344+
{"transparent-png-use-original.png", "image/jxl"},
345+
{"logo.png", "image/jxl"},
346+
})
347+
}
348+
322349
func TestImageMagickProcessor_Optimise_Avif_Webp(t *testing.T) {
323350
qualities := []img.Quality{img.DEFAULT, img.LOW, img.LOWER}
324351

@@ -349,6 +376,66 @@ func TestImageMagickProcessor_Optimise_Avif_Webp(t *testing.T) {
349376
}
350377
}
351378

379+
func TestImageMagickProcessor_Optimise_Jxl_Avif_Webp(t *testing.T) {
380+
qualities := []img.Quality{img.DEFAULT, img.LOW, img.LOWER}
381+
382+
for _, q := range qualities {
383+
t.Run(fmt.Sprintf("Quality_%d", q), func(t *testing.T) {
384+
testImages(t, func(orig []byte, imgId string) (*img.Image, error) {
385+
return proc.Optimise(&img.TransformationConfig{
386+
Src: &img.Image{
387+
Id: imgId,
388+
Data: orig,
389+
},
390+
Quality: q,
391+
SupportedFormats: []string{"image/jxl", "image/avif", "image/webp"},
392+
})
393+
},
394+
[]*testTransformation{
395+
{"big-jpeg.jpg", "image/webp"},
396+
{"medium-jpeg.jpg", "image/avif"},
397+
{"opaque-png.png", "image/avif"},
398+
{"animated.gif", "image/webp"},
399+
{"animated-coalesce.gif", "image/webp"},
400+
{"transparent-png.png", "image/avif"},
401+
{"small-transparent-png.png", "image/jxl"},
402+
{"transparent-png-use-original.png", "image/jxl"},
403+
{"logo.png", "image/jxl"},
404+
})
405+
})
406+
}
407+
}
408+
409+
func TestImageMagickProcessor_Optimise_Jxl_Webp(t *testing.T) {
410+
qualities := []img.Quality{img.DEFAULT, img.LOW, img.LOWER}
411+
412+
for _, q := range qualities {
413+
t.Run(fmt.Sprintf("Quality_%d", q), func(t *testing.T) {
414+
testImages(t, func(orig []byte, imgId string) (*img.Image, error) {
415+
return proc.Optimise(&img.TransformationConfig{
416+
Src: &img.Image{
417+
Id: imgId,
418+
Data: orig,
419+
},
420+
Quality: q,
421+
SupportedFormats: []string{"image/jxl", "image/webp"},
422+
})
423+
},
424+
[]*testTransformation{
425+
{"big-jpeg.jpg", "image/webp"},
426+
{"medium-jpeg.jpg", "image/jxl"},
427+
{"opaque-png.png", "image/jxl"},
428+
{"animated.gif", "image/webp"},
429+
{"animated-coalesce.gif", "image/webp"},
430+
{"transparent-png.png", "image/jxl"},
431+
{"small-transparent-png.png", "image/jxl"},
432+
{"transparent-png-use-original.png", "image/jxl"},
433+
{"logo.png", "image/jxl"},
434+
})
435+
})
436+
}
437+
}
438+
352439
func TestImageMagickProcessor_Resize_Avif(t *testing.T) {
353440
testImages(t, func(orig []byte, imgId string) (*img.Image, error) {
354441
return proc.Resize(&img.TransformationConfig{
@@ -371,25 +458,46 @@ func TestImageMagickProcessor_Resize_Avif(t *testing.T) {
371458
})
372459
}
373460

374-
func TestImageMagickProcessor_FitToSize_Avif(t *testing.T) {
461+
func TestImageMagickProcessor_Resize_Jxl(t *testing.T) {
462+
testImages(t, func(orig []byte, imgId string) (*img.Image, error) {
463+
return proc.Resize(&img.TransformationConfig{
464+
Src: &img.Image{
465+
Id: imgId,
466+
Data: orig,
467+
},
468+
SupportedFormats: []string{"image/jxl"},
469+
Config: &img.ResizeConfig{Size: "50"},
470+
})
471+
},
472+
[]*testTransformation{
473+
{"big-jpeg.jpg", "image/jxl"},
474+
{"medium-jpeg.jpg", "image/jxl"},
475+
{"opaque-png.png", "image/jxl"},
476+
{"animated.gif", ""},
477+
{"transparent-png-use-original.png", "image/jxl"},
478+
{"logo.png", "image/jxl"},
479+
})
480+
}
481+
482+
func TestImageMagickProcessor_FitToSize_Jxl(t *testing.T) {
375483
testImages(t, func(orig []byte, imgId string) (*img.Image, error) {
376484
return proc.FitToSize(&img.TransformationConfig{
377485
Src: &img.Image{
378486
Id: imgId,
379487
Data: orig,
380488
},
381-
SupportedFormats: []string{"image/avif"},
489+
SupportedFormats: []string{"image/jxl"},
382490
Config: &img.ResizeConfig{Size: "50x50"},
383491
})
384492
},
385493
[]*testTransformation{
386-
{"big-jpeg.jpg", "image/avif"},
387-
{"medium-jpeg.jpg", "image/avif"},
388-
{"opaque-png.png", "image/avif"},
494+
{"big-jpeg.jpg", "image/jxl"},
495+
{"medium-jpeg.jpg", "image/jxl"},
496+
{"opaque-png.png", "image/jxl"},
389497
{"animated.gif", ""},
390498
{"animated-coalesce.gif", ""},
391-
{"transparent-png-use-original.png", ""},
392-
{"logo.png", ""},
499+
{"transparent-png-use-original.png", "image/jxl"},
500+
{"logo.png", "image/jxl"},
393501
})
394502
}
395503

perf-test-avif.jmx

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.3">
2+
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.2">
33
<hashTree>
44
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Test Plan" enabled="true">
55
<stringProp name="TestPlan.comments"></stringProp>
@@ -14,8 +14,8 @@
1414
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Users" enabled="true">
1515
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
1616
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
17-
<boolProp name="LoopController.continue_forever">false</boolProp>
1817
<stringProp name="LoopController.loops">10</stringProp>
18+
<boolProp name="LoopController.continue_forever">false</boolProp>
1919
</elementProp>
2020
<stringProp name="ThreadGroup.num_threads">50</stringProp>
2121
<stringProp name="ThreadGroup.ramp_time">1</stringProp>
@@ -25,6 +25,7 @@
2525
<stringProp name="ThreadGroup.duration"></stringProp>
2626
<stringProp name="ThreadGroup.delay"></stringProp>
2727
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
28+
<boolProp name="ThreadGroup.delayedStart">false</boolProp>
2829
</ThreadGroup>
2930
<hashTree>
3031
<ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" testname="HTTP Request Defaults" enabled="true">
@@ -33,31 +34,26 @@
3334
</elementProp>
3435
<stringProp name="HTTPSampler.domain">localhost</stringProp>
3536
<stringProp name="HTTPSampler.port">8080</stringProp>
36-
<stringProp name="HTTPSampler.protocol"></stringProp>
37-
<stringProp name="HTTPSampler.contentEncoding"></stringProp>
3837
<stringProp name="HTTPSampler.path">/img</stringProp>
39-
<stringProp name="HTTPSampler.concurrentPool">6</stringProp>
40-
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
41-
<stringProp name="HTTPSampler.response_timeout"></stringProp>
4238
</ConfigTestElement>
4339
<hashTree/>
4440
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="HTTP Request" enabled="true">
41+
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
4542
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
4643
<collectionProp name="Arguments.arguments"/>
4744
</elementProp>
48-
<stringProp name="HTTPSampler.domain"></stringProp>
49-
<stringProp name="HTTPSampler.port"></stringProp>
50-
<stringProp name="HTTPSampler.protocol"></stringProp>
51-
<stringProp name="HTTPSampler.contentEncoding"></stringProp>
52-
<stringProp name="HTTPSampler.path">/img/http%3A%2F%2Fnginx/HT_Paper.png/optimise</stringProp>
45+
<stringProp name="HTTPSampler.path">/img/http%3A%2F%2Fnginx/transformations/big-jpeg.jpg/fit?size=1900x1900</stringProp>
5346
<stringProp name="HTTPSampler.method">GET</stringProp>
5447
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
5548
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
5649
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
5750
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
58-
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
59-
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
60-
<stringProp name="HTTPSampler.response_timeout"></stringProp>
51+
<boolProp name="HTTPSampler.BROWSER_COMPATIBLE_MULTIPART">false</boolProp>
52+
<boolProp name="HTTPSampler.image_parser">false</boolProp>
53+
<boolProp name="HTTPSampler.concurrentDwn">false</boolProp>
54+
<stringProp name="HTTPSampler.concurrentPool">6</stringProp>
55+
<boolProp name="HTTPSampler.md5">false</boolProp>
56+
<intProp name="HTTPSampler.ipSourceType">0</intProp>
6157
</HTTPSamplerProxy>
6258
<hashTree>
6359
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">

0 commit comments

Comments
 (0)