Skip to content

Commit

Permalink
Add experimental support for JPEG-XL, requires libvips with libjxl
Browse files Browse the repository at this point in the history
The prebuilt binaries do not include support for this format.
  • Loading branch information
lovell committed Dec 13, 2022
1 parent f92e33f commit a7fa701
Show file tree
Hide file tree
Showing 11 changed files with 253 additions and 3 deletions.
32 changes: 32 additions & 0 deletions docs/api-output.md
Expand Up @@ -536,6 +536,38 @@ Returns **Sharp** 

* **since**: 0.23.0

## jxl

Use these JPEG-XL (JXL) options for output image.

This feature is experimental, please do not use in production systems.

Requires libvips compiled with support for libjxl.
The prebuilt binaries do not include this - see
[installing a custom libvips][14].

Image metadata (EXIF, XMP) is unsupported.

### Parameters

* `options` **[Object][6]?** output options

* `options.distance` **[number][12]** maximum encoding error, between 0 (highest quality) and 15 (lowest quality) (optional, default `1.0`)
* `options.quality` **[number][12]?** calculate `distance` based on JPEG-like quality, between 1 and 100, overrides distance if specified
* `options.decodingTier` **[number][12]** target decode speed tier, between 0 (highest quality) and 4 (lowest quality) (optional, default `0`)
* `options.lossless` **[boolean][10]** use lossless compression (optional, default `false`)
* `options.effort` **[number][12]** CPU effort, between 3 (fastest) and 9 (slowest) (optional, default `7`)

<!---->

* Throws **[Error][4]** Invalid options

Returns **Sharp**&#x20;

**Meta**

* **since**: 0.31.3

## raw

Force output to be raw, uncompressed pixel data.
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.md
Expand Up @@ -6,6 +6,9 @@ Requires libvips v8.13.3

### v0.31.3 - TBD

* Add experimental support for JPEG-XL images. Requires libvips compiled with libjxl.
[#2731](https://github.com/lovell/sharp/issues/2731)

* Expose `interFrameMaxError` and `interPaletteMaxError` GIF optimisation properties.
[#3401](https://github.com/lovell/sharp/issues/3401)

Expand Down
2 changes: 1 addition & 1 deletion docs/search-index.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions lib/constructor.js
Expand Up @@ -308,6 +308,10 @@ const Sharp = function (input, options) {
heifCompression: 'av1',
heifEffort: 4,
heifChromaSubsampling: '4:4:4',
jxlDistance: 1,
jxlDecodingTier: 0,
jxlEffort: 7,
jxlLossless: false,
rawDepth: 'uchar',
tileSize: 256,
tileOverlap: 0,
Expand Down
69 changes: 68 additions & 1 deletion lib/output.js
Expand Up @@ -22,7 +22,8 @@ const formats = new Map([
['jp2', 'jp2'],
['jpx', 'jp2'],
['j2k', 'jp2'],
['j2c', 'jp2']
['j2c', 'jp2'],
['jxl', 'jxl']
]);

const jp2Regex = /\.jp[2x]|j2[kc]$/i;
Expand Down Expand Up @@ -938,6 +939,71 @@ function heif (options) {
return this._updateFormatOut('heif', options);
}

/**
* Use these JPEG-XL (JXL) options for output image.
*
* This feature is experimental, please do not use in production systems.
*
* Requires libvips compiled with support for libjxl.
* The prebuilt binaries do not include this - see
* {@link https://sharp.pixelplumbing.com/install#custom-libvips installing a custom libvips}.
*
* Image metadata (EXIF, XMP) is unsupported.
*
* @since 0.31.3
*
* @param {Object} [options] - output options
* @param {number} [options.distance=1.0] - maximum encoding error, between 0 (highest quality) and 15 (lowest quality)
* @param {number} [options.quality] - calculate `distance` based on JPEG-like quality, between 1 and 100, overrides distance if specified
* @param {number} [options.decodingTier=0] - target decode speed tier, between 0 (highest quality) and 4 (lowest quality)
* @param {boolean} [options.lossless=false] - use lossless compression
* @param {number} [options.effort=7] - CPU effort, between 3 (fastest) and 9 (slowest)
* @returns {Sharp}
* @throws {Error} Invalid options
*/
function jxl (options) {
if (is.object(options)) {
if (is.defined(options.quality)) {
if (is.integer(options.quality) && is.inRange(options.quality, 1, 100)) {
// https://github.com/libjxl/libjxl/blob/0aeea7f180bafd6893c1db8072dcb67d2aa5b03d/tools/cjxl_main.cc#L640-L644
this.options.jxlDistance = options.quality >= 30
? 0.1 + (100 - options.quality) * 0.09
: 53 / 3000 * options.quality * options.quality - 23 / 20 * options.quality + 25;
} else {
throw is.invalidParameterError('quality', 'integer between 1 and 100', options.quality);
}
} else if (is.defined(options.distance)) {
if (is.number(options.distance) && is.inRange(options.distance, 0, 15)) {
this.options.jxlDistance = options.distance;
} else {
throw is.invalidParameterError('distance', 'number between 0.0 and 15.0', options.distance);
}
}
if (is.defined(options.decodingTier)) {
if (is.integer(options.decodingTier) && is.inRange(options.decodingTier, 0, 4)) {
this.options.jxlDecodingTier = options.decodingTier;
} else {
throw is.invalidParameterError('decodingTier', 'integer between 0 and 4', options.decodingTier);
}
}
if (is.defined(options.lossless)) {
if (is.bool(options.lossless)) {
this.options.jxlLossless = options.lossless;
} else {
throw is.invalidParameterError('lossless', 'boolean', options.lossless);
}
}
if (is.defined(options.effort)) {
if (is.integer(options.effort) && is.inRange(options.effort, 3, 9)) {
this.options.jxlEffort = options.effort;
} else {
throw is.invalidParameterError('effort', 'integer between 3 and 9', options.effort);
}
}
}
return this._updateFormatOut('jxl', options);
}

/**
* Force output to be raw, uncompressed pixel data.
* Pixel ordering is left-to-right, top-to-bottom, without padding.
Expand Down Expand Up @@ -1308,6 +1374,7 @@ module.exports = function (Sharp) {
tiff,
avif,
heif,
jxl,
gif,
raw,
tile,
Expand Down
6 changes: 6 additions & 0 deletions src/common.cc
Expand Up @@ -207,6 +207,9 @@ namespace sharp {
bool IsAvif(std::string const &str) {
return EndsWith(str, ".avif") || EndsWith(str, ".AVIF");
}
bool IsJxl(std::string const &str) {
return EndsWith(str, ".jxl") || EndsWith(str, ".JXL");
}
bool IsDz(std::string const &str) {
return EndsWith(str, ".dzi") || EndsWith(str, ".DZI");
}
Expand Down Expand Up @@ -237,6 +240,7 @@ namespace sharp {
case ImageType::PPM: id = "ppm"; break;
case ImageType::FITS: id = "fits"; break;
case ImageType::EXR: id = "exr"; break;
case ImageType::JXL: id = "jxl"; break;
case ImageType::VIPS: id = "vips"; break;
case ImageType::RAW: id = "raw"; break;
case ImageType::UNKNOWN: id = "unknown"; break;
Expand Down Expand Up @@ -281,6 +285,8 @@ namespace sharp {
{ "VipsForeignLoadPpmFile", ImageType::PPM },
{ "VipsForeignLoadFitsFile", ImageType::FITS },
{ "VipsForeignLoadOpenexr", ImageType::EXR },
{ "VipsForeignLoadJxlFile", ImageType::JXL },
{ "VipsForeignLoadJxlBuffer", ImageType::JXL },
{ "VipsForeignLoadVips", ImageType::VIPS },
{ "VipsForeignLoadVipsFile", ImageType::VIPS },
{ "VipsForeignLoadRaw", ImageType::RAW }
Expand Down
2 changes: 2 additions & 0 deletions src/common.h
Expand Up @@ -152,6 +152,7 @@ namespace sharp {
PPM,
FITS,
EXR,
JXL,
VIPS,
RAW,
UNKNOWN,
Expand Down Expand Up @@ -182,6 +183,7 @@ namespace sharp {
bool IsHeic(std::string const &str);
bool IsHeif(std::string const &str);
bool IsAvif(std::string const &str);
bool IsJxl(std::string const &str);
bool IsDz(std::string const &str);
bool IsDzZip(std::string const &str);
bool IsV(std::string const &str);
Expand Down
31 changes: 31 additions & 0 deletions src/pipeline.cc
Expand Up @@ -939,6 +939,21 @@ class PipelineWorker : public Napi::AsyncWorker {
area->free_fn = nullptr;
vips_area_unref(area);
baton->formatOut = "dz";
} else if (baton->formatOut == "jxl" ||
(baton->formatOut == "input" && inputImageType == sharp::ImageType::JXL)) {
// Write JXL to buffer
image = sharp::RemoveAnimationProperties(image);
VipsArea *area = reinterpret_cast<VipsArea*>(image.jxlsave_buffer(VImage::option()
->set("strip", !baton->withMetadata)
->set("distance", baton->jxlDistance)
->set("tier", baton->jxlDecodingTier)
->set("effort", baton->jxlEffort)
->set("lossless", baton->jxlLossless)));
baton->bufferOut = static_cast<char*>(area->data);
baton->bufferOutLength = area->length;
area->free_fn = nullptr;
vips_area_unref(area);
baton->formatOut = "jxl";
} else if (baton->formatOut == "raw" ||
(baton->formatOut == "input" && inputImageType == sharp::ImageType::RAW)) {
// Write raw, uncompressed image data to buffer
Expand Down Expand Up @@ -977,6 +992,7 @@ class PipelineWorker : public Napi::AsyncWorker {
bool const isTiff = sharp::IsTiff(baton->fileOut);
bool const isJp2 = sharp::IsJp2(baton->fileOut);
bool const isHeif = sharp::IsHeif(baton->fileOut);
bool const isJxl = sharp::IsJxl(baton->fileOut);
bool const isDz = sharp::IsDz(baton->fileOut);
bool const isDzZip = sharp::IsDzZip(baton->fileOut);
bool const isV = sharp::IsV(baton->fileOut);
Expand Down Expand Up @@ -1094,6 +1110,17 @@ class PipelineWorker : public Napi::AsyncWorker {
? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON)
->set("lossless", baton->heifLossless));
baton->formatOut = "heif";
} else if (baton->formatOut == "jxl" || (mightMatchInput && isJxl) ||
(willMatchInput && inputImageType == sharp::ImageType::JXL)) {
// Write JXL to file
image = sharp::RemoveAnimationProperties(image);
image.jxlsave(const_cast<char*>(baton->fileOut.data()), VImage::option()
->set("strip", !baton->withMetadata)
->set("distance", baton->jxlDistance)
->set("tier", baton->jxlDecodingTier)
->set("effort", baton->jxlEffort)
->set("lossless", baton->jxlLossless));
baton->formatOut = "jxl";
} else if (baton->formatOut == "dz" || isDz || isDzZip) {
// Write DZ to file
if (isDzZip) {
Expand Down Expand Up @@ -1579,6 +1606,10 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
options, "heifCompression", VIPS_TYPE_FOREIGN_HEIF_COMPRESSION);
baton->heifEffort = sharp::AttrAsUint32(options, "heifEffort");
baton->heifChromaSubsampling = sharp::AttrAsStr(options, "heifChromaSubsampling");
baton->jxlDistance = sharp::AttrAsDouble(options, "jxlDistance");
baton->jxlDecodingTier = sharp::AttrAsUint32(options, "jxlDecodingTier");
baton->jxlEffort = sharp::AttrAsUint32(options, "jxlEffort");
baton->jxlLossless = sharp::AttrAsBool(options, "jxlLossless");
baton->rawDepth = sharp::AttrAsEnum<VipsBandFormat>(options, "rawDepth", VIPS_TYPE_BAND_FORMAT);
// Animated output properties
if (sharp::HasAttr(options, "loop")) {
Expand Down
8 changes: 8 additions & 0 deletions src/pipeline.h
Expand Up @@ -182,6 +182,10 @@ struct PipelineBaton {
int heifEffort;
std::string heifChromaSubsampling;
bool heifLossless;
double jxlDistance;
int jxlDecodingTier;
int jxlEffort;
bool jxlLossless;
VipsBandFormat rawDepth;
std::string err;
bool withMetadata;
Expand Down Expand Up @@ -335,6 +339,10 @@ struct PipelineBaton {
heifEffort(4),
heifChromaSubsampling("4:4:4"),
heifLossless(false),
jxlDistance(1.0),
jxlDecodingTier(0),
jxlEffort(7),
jxlLossless(false),
rawDepth(VIPS_FORMAT_UCHAR),
withMetadata(false),
withMetadataOrientation(-1),
Expand Down
2 changes: 1 addition & 1 deletion src/utilities.cc
Expand Up @@ -115,7 +115,7 @@ Napi::Value format(const Napi::CallbackInfo& info) {
Napi::Object format = Napi::Object::New(env);
for (std::string const f : {
"jpeg", "png", "webp", "tiff", "magick", "openslide", "dz",
"ppm", "fits", "gif", "svg", "heif", "pdf", "vips", "jp2k"
"ppm", "fits", "gif", "svg", "heif", "pdf", "vips", "jp2k", "jxl"
}) {
// Input
const VipsObjectClass *oc = vips_class_find("VipsOperation", (f + "load").c_str());
Expand Down
97 changes: 97 additions & 0 deletions test/unit/jxl.js
@@ -0,0 +1,97 @@
'use strict';

const assert = require('assert');

const sharp = require('../../');

describe('JXL', () => {
it('called without options does not throw an error', () => {
assert.doesNotThrow(() => {
sharp().jxl();
});
});
it('valid distance does not throw an error', () => {
assert.doesNotThrow(() => {
sharp().jxl({ distance: 2.3 });
});
});
it('invalid distance should throw an error', () => {
assert.throws(() => {
sharp().jxl({ distance: 15.1 });
});
});
it('non-numeric distance should throw an error', () => {
assert.throws(() => {
sharp().jxl({ distance: 'fail' });
});
});
it('valid quality > 30 does not throw an error', () => {
const s = sharp();
assert.doesNotThrow(() => {
s.jxl({ quality: 80 });
});
assert.strictEqual(s.options.jxlDistance, 1.9);
});
it('valid quality < 30 does not throw an error', () => {
const s = sharp();
assert.doesNotThrow(() => {
s.jxl({ quality: 20 });
});
assert.strictEqual(s.options.jxlDistance, 9.066666666666666);
});
it('valid quality does not throw an error', () => {
assert.doesNotThrow(() => {
sharp().jxl({ quality: 80 });
});
});
it('invalid quality should throw an error', () => {
assert.throws(() => {
sharp().jxl({ quality: 101 });
});
});
it('non-numeric quality should throw an error', () => {
assert.throws(() => {
sharp().jxl({ quality: 'fail' });
});
});
it('valid decodingTier does not throw an error', () => {
assert.doesNotThrow(() => {
sharp().jxl({ decodingTier: 2 });
});
});
it('invalid decodingTier should throw an error', () => {
assert.throws(() => {
sharp().jxl({ decodingTier: 5 });
});
});
it('non-numeric decodingTier should throw an error', () => {
assert.throws(() => {
sharp().jxl({ decodingTier: 'fail' });
});
});
it('valid lossless does not throw an error', () => {
assert.doesNotThrow(() => {
sharp().jxl({ lossless: true });
});
});
it('non-boolean lossless should throw an error', () => {
assert.throws(() => {
sharp().jxl({ lossless: 'fail' });
});
});
it('valid effort does not throw an error', () => {
assert.doesNotThrow(() => {
sharp().jxl({ effort: 6 });
});
});
it('out of range effort should throw an error', () => {
assert.throws(() => {
sharp().jxl({ effort: 10 });
});
});
it('invalid effort should throw an error', () => {
assert.throws(() => {
sharp().jxl({ effort: 'fail' });
});
});
});

0 comments on commit a7fa701

Please sign in to comment.