Skip to content

Commit

Permalink
Lossless image optimizer for PNGs and JPEGs (#6762)
Browse files Browse the repository at this point in the history
  • Loading branch information
devongovett authored Aug 27, 2021
1 parent 1452a30 commit b858801
Show file tree
Hide file tree
Showing 15 changed files with 820 additions and 6 deletions.
525 changes: 521 additions & 4 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ members = [
"packages/transformers/js/wasm",
"packages/utils/fs-search",
"packages/utils/hash",
"packages/optimizers/image"
]
3 changes: 2 additions & 1 deletion packages/configs/default/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
"*.css": ["@parcel/optimizer-cssnano"],
"*.html": ["@parcel/optimizer-htmlnano"],
"*.{js,mjs,cjs}": ["@parcel/optimizer-terser"],
"*.svg": ["@parcel/optimizer-svgo"]
"*.svg": ["@parcel/optimizer-svgo"],
"*.{jpg,jpeg,png}": ["@parcel/optimizer-image"]
},
"packagers": {
"*.html": "@parcel/packager-html",
Expand Down
1 change: 1 addition & 0 deletions packages/configs/default/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@parcel/namer-default": "2.0.0-rc.0",
"@parcel/optimizer-cssnano": "2.0.0-rc.0",
"@parcel/optimizer-htmlnano": "2.0.0-rc.0",
"@parcel/optimizer-image": "2.0.0-rc.0",
"@parcel/optimizer-svgo": "2.0.0-rc.0",
"@parcel/optimizer-terser": "2.0.0-rc.0",
"@parcel/packager-css": "2.0.0-rc.0",
Expand Down
48 changes: 47 additions & 1 deletion packages/core/integration-tests/test/image.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from 'assert';
import {bundle, distDir, outputFS} from '@parcel/test-utils';
import {bundle, distDir, inputFS, outputFS} from '@parcel/test-utils';
import path from 'path';
import sharp from 'sharp';

Expand Down Expand Up @@ -88,4 +88,50 @@ describe('image', function() {
);
});
});

it('should optimise JPEGs', async function() {
let img = path.join(__dirname, '/integration/image/image.jpg');
let b = await bundle(img, {
defaultTargetOptions: {
shouldOptimize: true,
},
});

const imagePath = b.getBundles().find(b => b.type === 'jpg').filePath;

let input = await inputFS.readFile(img);
let inputRaw = await sharp(input)
.toFormat('raw')
.toBuffer();
let output = await outputFS.readFile(imagePath);
let outputRaw = await sharp(output)
.toFormat('raw')
.toBuffer();

assert(outputRaw.equals(inputRaw));
assert(output.length < input.length);
});

it('should optimise PNGs', async function() {
let img = path.join(__dirname, '/integration/image/clock.png');
let b = await bundle(img, {
defaultTargetOptions: {
shouldOptimize: true,
},
});

const imagePath = b.getBundles().find(b => b.type === 'png').filePath;

let input = await inputFS.readFile(img);
let inputRaw = await sharp(input)
.toFormat('raw')
.toBuffer();
let output = await outputFS.readFile(imagePath);
let outputRaw = await sharp(output)
.toFormat('raw')
.toBuffer();

assert(outputRaw.equals(inputRaw));
assert(output.length < input.length);
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/optimizers/image/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.node
17 changes: 17 additions & 0 deletions packages/optimizers/image/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
authors = ["Devon Govett <[email protected]>"]
name = "parcel-image"
version = "0.1.0"

[lib]
crate-type = ["cdylib"]

[dependencies]
napi = "1"
napi-derive = "1"
oxipng = "5.0.0"
mozjpeg-sys = "1.0.0"
libc = "0.2"

[build-dependencies]
napi-build = "1"
5 changes: 5 additions & 0 deletions packages/optimizers/image/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
extern crate napi_build;

fn main() {
napi_build::setup();
}
22 changes: 22 additions & 0 deletions packages/optimizers/image/native.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
let parts = [process.platform, process.arch];
if (process.platform === 'linux') {
const {MUSL, family} = require('detect-libc');
if (family === MUSL) {
parts.push('musl');
} else if (process.arch === 'arm') {
parts.push('gnueabihf');
} else {
parts.push('gnu');
}
} else if (process.platform === 'win32') {
parts.push('msvc');
}

let name = `./parcel-image.${parts.join('-')}.node`;
if (process.env.PARCEL_BUILD_ENV === 'production') {
module.exports = require(name);
} else if (require('fs').existsSync(require('path').join(__dirname, name))) {
module.exports = require(name);
}

module.exports.init = Promise.resolve();
3 changes: 3 additions & 0 deletions packages/optimizers/image/native.js.flow
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// @flow

declare export function optimize(type: string, buffer: Buffer): Buffer;
43 changes: 43 additions & 0 deletions packages/optimizers/image/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@parcel/optimizer-image",
"version": "2.0.0-rc.0",
"license": "MIT",
"main": "lib/ImageOptimizer.js",
"source": "src/ImageOptimizer.js",
"publishConfig": {
"access": "public"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"repository": {
"type": "git",
"url": "https://github.com/parcel-bundler/parcel.git"
},
"engines": {
"node": ">= 12.0.0",
"parcel": "^2.0.0-beta.1"
},
"files": [
"lib",
"native.js",
"*.node"
],
"napi": {
"name": "parcel-image"
},
"scripts": {
"build": "napi build --platform",
"build-release": "napi build --platform --release"
},
"dependencies": {
"@parcel/plugin": "2.0.0-rc.0",
"@parcel/utils": "2.0.0-rc.0",
"detect-libc": "^1.0.3"
},
"devDependencies": {
"@napi-rs/cli": "1.0.4",
"tiny-benchy": "^1.0.2"
}
}
14 changes: 14 additions & 0 deletions packages/optimizers/image/src/ImageOptimizer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// @flow
import {Optimizer} from '@parcel/plugin';
import {blobToBuffer} from '@parcel/utils';
import {optimize} from '../native';

export default (new Optimizer({
async optimize({bundle, contents}) {
let buffer = await blobToBuffer(contents);
let optimized = optimize(bundle.type, buffer);
return {
contents: optimized.length < buffer.length ? optimized : buffer,
};
},
}): Optimizer);
143 changes: 143 additions & 0 deletions packages/optimizers/image/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
extern crate napi;
#[macro_use]
extern crate napi_derive;
extern crate libc;
extern crate mozjpeg_sys;
extern crate oxipng;

use mozjpeg_sys::*;
use napi::{CallContext, Env, Error, JsBuffer, JsObject, JsString, Result};
use oxipng::{optimize_from_memory, Deflaters, Headers, Options};
use std::mem;
use std::ptr;
use std::slice;

#[js_function(2)]
fn optimize(ctx: CallContext) -> Result<JsBuffer> {
let k = ctx.get::<JsString>(0)?.into_utf8()?;
let kind = k.as_str()?;
let buf = ctx.get::<JsBuffer>(1)?.into_value()?;
let slice = buf.as_ref();

match kind {
"png" => {
let mut options = Options::default();
options.deflate = Deflaters::Libdeflater;
options.strip = Headers::Safe;
match optimize_from_memory(slice, &options) {
Ok(res) => Ok(ctx.env.create_buffer_with_data(res)?.into_raw()),
Err(err) => Err(Error::from_reason(format!("{}", err))),
}
}
"jpg" | "jpeg" => unsafe {
match optimize_jpeg(slice) {
Ok(res) => Ok(
ctx
.env
.create_buffer_with_borrowed_data(res.as_ptr(), res.len(), res.as_mut_ptr(), finalize)?
.into_raw(),
),
Err(err) => {
if let Some(msg) = err.downcast_ref::<String>() {
Err(Error::from_reason(msg.to_string()))
} else {
Err(Error::from_reason("Unknown libjpeg error".into()))
}
}
}
},
_ => Err(Error::from_reason(format!("Unknown image type {}", kind))),
}
}

fn finalize(ptr: *mut u8, _env: Env) {
unsafe {
libc::free(ptr as *mut c_void);
}
}

struct JPEGOptimizer {
srcinfo: jpeg_decompress_struct,
dstinfo: jpeg_compress_struct,
}

impl JPEGOptimizer {
unsafe fn new() -> JPEGOptimizer {
JPEGOptimizer {
srcinfo: mem::zeroed(),
dstinfo: mem::zeroed(),
}
}
}

impl Drop for JPEGOptimizer {
fn drop(&mut self) {
unsafe {
jpeg_destroy_decompress(&mut self.srcinfo);
jpeg_destroy_compress(&mut self.dstinfo);
}
}
}

// This function losslessly optimizes jpegs.
// Based on the jpegtran.c example program in libjpeg.
unsafe fn optimize_jpeg(bytes: &[u8]) -> std::thread::Result<&mut [u8]> {
std::panic::catch_unwind(|| {
let mut info = JPEGOptimizer::new();
let mut err = create_error_handler();
info.srcinfo.common.err = &mut err;
jpeg_create_decompress(&mut info.srcinfo);
jpeg_mem_src(&mut info.srcinfo, bytes.as_ptr(), bytes.len() as c_ulong);

info.dstinfo.optimize_coding = 1;
info.dstinfo.common.err = &mut err;
jpeg_create_compress(&mut info.dstinfo);
jpeg_read_header(&mut info.srcinfo, 1);

let src_coef_arrays = jpeg_read_coefficients(&mut info.srcinfo);
jpeg_copy_critical_parameters(&mut info.srcinfo, &mut info.dstinfo);

let mut buf = ptr::null_mut();
let mut outsize: c_ulong = 0;
jpeg_mem_dest(&mut info.dstinfo, &mut buf, &mut outsize);

jpeg_write_coefficients(&mut info.dstinfo, src_coef_arrays);

jpeg_finish_compress(&mut info.dstinfo);
jpeg_finish_decompress(&mut info.srcinfo);

slice::from_raw_parts_mut(buf, outsize as usize)
})
}

unsafe fn create_error_handler() -> jpeg_error_mgr {
let mut err: jpeg_error_mgr = mem::zeroed();
jpeg_std_error(&mut err);
err.error_exit = Some(unwind_error_exit);
err.emit_message = Some(silence_message);
err
}

extern "C" fn unwind_error_exit(cinfo: &mut jpeg_common_struct) {
let message = unsafe {
let err = cinfo.err.as_ref().unwrap();
match err.format_message {
Some(fmt) => {
let buffer = mem::zeroed();
fmt(cinfo, &buffer);
let len = buffer.iter().take_while(|&&c| c != 0).count();
String::from_utf8_lossy(&buffer[..len]).into()
}
None => format!("libjpeg error: {}", err.msg_code),
}
};
std::panic::resume_unwind(Box::new(message))
}

extern "C" fn silence_message(_cinfo: &mut jpeg_common_struct, _level: c_int) {}

#[module_exports]
fn init(mut exports: JsObject, _env: Env) -> Result<()> {
exports.create_named_method("optimize", optimize)?;
Ok(())
}

0 comments on commit b858801

Please sign in to comment.