Add experimental support for JPEG-XL, requires libvips with libjxl

The prebuilt binaries do not include support for this format.
This commit is contained in:
Lovell Fuller 2022-12-13 11:37:08 +00:00
parent f92e33fbff
commit a7fa7014ef
11 changed files with 253 additions and 3 deletions

View File

@ -536,6 +536,38 @@ Returns **Sharp** 
* **since**: 0.23.0 * **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 ## raw
Force output to be raw, uncompressed pixel data. Force output to be raw, uncompressed pixel data.

View File

@ -6,6 +6,9 @@ Requires libvips v8.13.3
### v0.31.3 - TBD ### 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. * Expose `interFrameMaxError` and `interPaletteMaxError` GIF optimisation properties.
[#3401](https://github.com/lovell/sharp/issues/3401) [#3401](https://github.com/lovell/sharp/issues/3401)

File diff suppressed because one or more lines are too long

View File

@ -308,6 +308,10 @@ const Sharp = function (input, options) {
heifCompression: 'av1', heifCompression: 'av1',
heifEffort: 4, heifEffort: 4,
heifChromaSubsampling: '4:4:4', heifChromaSubsampling: '4:4:4',
jxlDistance: 1,
jxlDecodingTier: 0,
jxlEffort: 7,
jxlLossless: false,
rawDepth: 'uchar', rawDepth: 'uchar',
tileSize: 256, tileSize: 256,
tileOverlap: 0, tileOverlap: 0,

View File

@ -22,7 +22,8 @@ const formats = new Map([
['jp2', 'jp2'], ['jp2', 'jp2'],
['jpx', 'jp2'], ['jpx', 'jp2'],
['j2k', 'jp2'], ['j2k', 'jp2'],
['j2c', 'jp2'] ['j2c', 'jp2'],
['jxl', 'jxl']
]); ]);
const jp2Regex = /\.jp[2x]|j2[kc]$/i; const jp2Regex = /\.jp[2x]|j2[kc]$/i;
@ -938,6 +939,71 @@ function heif (options) {
return this._updateFormatOut('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. * Force output to be raw, uncompressed pixel data.
* Pixel ordering is left-to-right, top-to-bottom, without padding. * Pixel ordering is left-to-right, top-to-bottom, without padding.
@ -1308,6 +1374,7 @@ module.exports = function (Sharp) {
tiff, tiff,
avif, avif,
heif, heif,
jxl,
gif, gif,
raw, raw,
tile, tile,

View File

@ -207,6 +207,9 @@ namespace sharp {
bool IsAvif(std::string const &str) { bool IsAvif(std::string const &str) {
return EndsWith(str, ".avif") || EndsWith(str, ".AVIF"); 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) { bool IsDz(std::string const &str) {
return EndsWith(str, ".dzi") || EndsWith(str, ".DZI"); return EndsWith(str, ".dzi") || EndsWith(str, ".DZI");
} }
@ -237,6 +240,7 @@ namespace sharp {
case ImageType::PPM: id = "ppm"; break; case ImageType::PPM: id = "ppm"; break;
case ImageType::FITS: id = "fits"; break; case ImageType::FITS: id = "fits"; break;
case ImageType::EXR: id = "exr"; break; case ImageType::EXR: id = "exr"; break;
case ImageType::JXL: id = "jxl"; break;
case ImageType::VIPS: id = "vips"; break; case ImageType::VIPS: id = "vips"; break;
case ImageType::RAW: id = "raw"; break; case ImageType::RAW: id = "raw"; break;
case ImageType::UNKNOWN: id = "unknown"; break; case ImageType::UNKNOWN: id = "unknown"; break;
@ -281,6 +285,8 @@ namespace sharp {
{ "VipsForeignLoadPpmFile", ImageType::PPM }, { "VipsForeignLoadPpmFile", ImageType::PPM },
{ "VipsForeignLoadFitsFile", ImageType::FITS }, { "VipsForeignLoadFitsFile", ImageType::FITS },
{ "VipsForeignLoadOpenexr", ImageType::EXR }, { "VipsForeignLoadOpenexr", ImageType::EXR },
{ "VipsForeignLoadJxlFile", ImageType::JXL },
{ "VipsForeignLoadJxlBuffer", ImageType::JXL },
{ "VipsForeignLoadVips", ImageType::VIPS }, { "VipsForeignLoadVips", ImageType::VIPS },
{ "VipsForeignLoadVipsFile", ImageType::VIPS }, { "VipsForeignLoadVipsFile", ImageType::VIPS },
{ "VipsForeignLoadRaw", ImageType::RAW } { "VipsForeignLoadRaw", ImageType::RAW }

View File

@ -152,6 +152,7 @@ namespace sharp {
PPM, PPM,
FITS, FITS,
EXR, EXR,
JXL,
VIPS, VIPS,
RAW, RAW,
UNKNOWN, UNKNOWN,
@ -182,6 +183,7 @@ namespace sharp {
bool IsHeic(std::string const &str); bool IsHeic(std::string const &str);
bool IsHeif(std::string const &str); bool IsHeif(std::string const &str);
bool IsAvif(std::string const &str); bool IsAvif(std::string const &str);
bool IsJxl(std::string const &str);
bool IsDz(std::string const &str); bool IsDz(std::string const &str);
bool IsDzZip(std::string const &str); bool IsDzZip(std::string const &str);
bool IsV(std::string const &str); bool IsV(std::string const &str);

View File

@ -939,6 +939,21 @@ class PipelineWorker : public Napi::AsyncWorker {
area->free_fn = nullptr; area->free_fn = nullptr;
vips_area_unref(area); vips_area_unref(area);
baton->formatOut = "dz"; 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" || } else if (baton->formatOut == "raw" ||
(baton->formatOut == "input" && inputImageType == sharp::ImageType::RAW)) { (baton->formatOut == "input" && inputImageType == sharp::ImageType::RAW)) {
// Write raw, uncompressed image data to buffer // Write raw, uncompressed image data to buffer
@ -977,6 +992,7 @@ class PipelineWorker : public Napi::AsyncWorker {
bool const isTiff = sharp::IsTiff(baton->fileOut); bool const isTiff = sharp::IsTiff(baton->fileOut);
bool const isJp2 = sharp::IsJp2(baton->fileOut); bool const isJp2 = sharp::IsJp2(baton->fileOut);
bool const isHeif = sharp::IsHeif(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 isDz = sharp::IsDz(baton->fileOut);
bool const isDzZip = sharp::IsDzZip(baton->fileOut); bool const isDzZip = sharp::IsDzZip(baton->fileOut);
bool const isV = sharp::IsV(baton->fileOut); bool const isV = sharp::IsV(baton->fileOut);
@ -1094,6 +1110,17 @@ class PipelineWorker : public Napi::AsyncWorker {
? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON) ? VIPS_FOREIGN_SUBSAMPLE_OFF : VIPS_FOREIGN_SUBSAMPLE_ON)
->set("lossless", baton->heifLossless)); ->set("lossless", baton->heifLossless));
baton->formatOut = "heif"; 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) { } else if (baton->formatOut == "dz" || isDz || isDzZip) {
// Write DZ to file // Write DZ to file
if (isDzZip) { if (isDzZip) {
@ -1579,6 +1606,10 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) {
options, "heifCompression", VIPS_TYPE_FOREIGN_HEIF_COMPRESSION); options, "heifCompression", VIPS_TYPE_FOREIGN_HEIF_COMPRESSION);
baton->heifEffort = sharp::AttrAsUint32(options, "heifEffort"); baton->heifEffort = sharp::AttrAsUint32(options, "heifEffort");
baton->heifChromaSubsampling = sharp::AttrAsStr(options, "heifChromaSubsampling"); 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); baton->rawDepth = sharp::AttrAsEnum<VipsBandFormat>(options, "rawDepth", VIPS_TYPE_BAND_FORMAT);
// Animated output properties // Animated output properties
if (sharp::HasAttr(options, "loop")) { if (sharp::HasAttr(options, "loop")) {

View File

@ -182,6 +182,10 @@ struct PipelineBaton {
int heifEffort; int heifEffort;
std::string heifChromaSubsampling; std::string heifChromaSubsampling;
bool heifLossless; bool heifLossless;
double jxlDistance;
int jxlDecodingTier;
int jxlEffort;
bool jxlLossless;
VipsBandFormat rawDepth; VipsBandFormat rawDepth;
std::string err; std::string err;
bool withMetadata; bool withMetadata;
@ -335,6 +339,10 @@ struct PipelineBaton {
heifEffort(4), heifEffort(4),
heifChromaSubsampling("4:4:4"), heifChromaSubsampling("4:4:4"),
heifLossless(false), heifLossless(false),
jxlDistance(1.0),
jxlDecodingTier(0),
jxlEffort(7),
jxlLossless(false),
rawDepth(VIPS_FORMAT_UCHAR), rawDepth(VIPS_FORMAT_UCHAR),
withMetadata(false), withMetadata(false),
withMetadataOrientation(-1), withMetadataOrientation(-1),

View File

@ -115,7 +115,7 @@ Napi::Value format(const Napi::CallbackInfo& info) {
Napi::Object format = Napi::Object::New(env); Napi::Object format = Napi::Object::New(env);
for (std::string const f : { for (std::string const f : {
"jpeg", "png", "webp", "tiff", "magick", "openslide", "dz", "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 // Input
const VipsObjectClass *oc = vips_class_find("VipsOperation", (f + "load").c_str()); const VipsObjectClass *oc = vips_class_find("VipsOperation", (f + "load").c_str());

97
test/unit/jxl.js Normal file
View File

@ -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' });
});
});
});