Skip to content

Commit

Permalink
Merge pull request #137 from kaliber5/blurhash
Browse files Browse the repository at this point in the history
Add support for blurhash-based LQIP
  • Loading branch information
simonihmig authored Feb 9, 2021
2 parents 4c108f7 + f483e39 commit 908666b
Show file tree
Hide file tree
Showing 11 changed files with 266 additions and 4 deletions.
1 change: 1 addition & 0 deletions addon/components/responsive-image.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
...attributes
{{style
(if this.showLqipImage (hash background-image=this.lqipImage background-size="cover"))
(if this.showLqipBlurhash (hash background-image=this.lqipBlurhash background-size="cover"))
(if this.showLqipColor (hash background-color=this.lqipColor))
}}
{{on "load" this.onLoad}}
Expand Down
61 changes: 61 additions & 0 deletions addon/components/responsive-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
import ResponsiveImageService, {
ImageMeta,
ImageType,
LqipBlurhash,
LqipColor,
LqipInline,
Meta,
Expand All @@ -12,6 +13,15 @@ import dataUri from 'ember-responsive-image/utils/data-uri';
import blurrySvg from 'ember-responsive-image/utils/blurry-svg';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import {
macroCondition,
getOwnConfig /*, importSync*/,
} from '@embroider/macros';
import { decode } from 'blurhash';

declare module '@embroider/macros' {
export function getOwnConfig(): { usesBlurhash: boolean };
}

interface ResponsiveImageComponentArgs {
image: string;
Expand All @@ -34,6 +44,11 @@ enum Layout {
}

const PIXEL_DENSITIES = [1, 2];
const canvas =
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
(typeof FastBoot === 'undefined' &&
document.createElement('canvas')) as HTMLCanvasElement;

// determines the order of sources, prefereing next-gen formats over legacy
const typeScore = new Map<ImageType, number>([
Expand Down Expand Up @@ -215,6 +230,52 @@ export default class ResponsiveImageComponent extends Component<ResponsiveImageC
return lqip.color;
}

get hasLqipBlurhash(): boolean {
if (macroCondition(getOwnConfig().usesBlurhash)) {
return this.meta.lqip?.type === 'blurhash';
} else {
return false;
}
}

get showLqipBlurhash(): boolean {
return !this.isLoaded && this.hasLqipBlurhash;
}

get lqipBlurhash(): string | undefined {
if (macroCondition(getOwnConfig().usesBlurhash)) {
if (!this.hasLqipBlurhash) {
return undefined;
}
const { hash, width, height } = (this.meta as Required<Meta>)
.lqip as LqipBlurhash;

// This does not work correctly, see https://github.com/embroider-build/embroider/issues/684
// The idea was to pull `blurhash` into our vendor.js only when needed
// Currently we are instead importing it at the module head, but this comes at a cost for all users that don't need it.
// const { decode } = importSync('blurhash') as any;

const blurWidth = width * 40;
const blurHeight = height * 40;
const pixels = decode(hash, blurWidth, blurHeight);
canvas.width = blurWidth;
canvas.height = blurHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return undefined;
}

const imageData = ctx.createImageData(blurWidth, blurHeight);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
const uri = canvas.toDataURL('image/png');

return `url("${uri}")`;
} else {
return undefined;
}
}

@action
onLoad(): void {
this.isLoaded = true;
Expand Down
9 changes: 8 additions & 1 deletion addon/services/responsive-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export interface LqipColor extends LqipBase {
color: string;
}

export interface LqipBlurhash extends LqipBase {
type: 'blurhash';
hash: string;
width: number;
height: number;
}

export interface ImageMeta {
image: string;
width: number;
Expand All @@ -32,7 +39,7 @@ export interface ImageMeta {

export interface Meta {
images: ImageMeta[];
lqip?: LqipInline | LqipColor;
lqip?: LqipInline | LqipColor | LqipBlurhash;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions ember-cli-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ module.exports = function (defaults) {
removeSource: true,
justCopy: false,
},
{
include: 'assets/images/lqip/blurhash.jpg',
quality: 50,
supportedWidths: [100, 640],
lqip: {
type: 'blurhash',
},
removeSource: true,
},
],
});

Expand Down
7 changes: 6 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const defaultConfig = {
*/
module.exports = {
name: require('./package').name,
options: {},
metaData: {},
configData: {},
app: null,
Expand All @@ -33,6 +32,12 @@ module.exports = {
imagePostProcessors: [],
plugins: [],

options: {
'@embroider/macros': {
setOwnConfig: {},
},
},

/**
* Add a callback function to change the generated metaData per origin image.
* The callback method you provide must have the following signature:
Expand Down
80 changes: 80 additions & 0 deletions lib/plugins/lqip-blurhash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const sharp = require('sharp');

class LqipBlurhashPlugin {
constructor(addon) {
this.processed = [];
this.metaData = new Map();

if (
addon.addonOptions.find(
(imageConfig) =>
imageConfig.lqip && imageConfig.lqip.type === 'blurhash'
)
) {
this.blurhash = require('blurhash');
addon.options['@embroider/macros'].setOwnConfig.usesBlurhash = true;

addon.addMetadataExtension(this.addMetaData, this);
addon.addImagePreProcessor(this.imagePreProcessor, this);
}
}

canProcessImage(config) {
return config.lqip && config.lqip.type === 'blurhash';
}

async getLqipDimensions(config, sharped) {
const meta = await sharped.metadata();
const targetPixels = config.lqip.targetPixels || 16;
const aspectRatio = meta.width / meta.height;

// taken from https://github.com/google/eleventy-high-performance-blog/blob/5ed39db7fd3f21ae82ac1a8e833bf283355bd3d0/_11ty/blurry-placeholder.js#L74-L92
let bitmapHeight = targetPixels / aspectRatio;
bitmapHeight = Math.sqrt(bitmapHeight);
const bitmapWidth = targetPixels / bitmapHeight;
return { width: Math.round(bitmapWidth), height: Math.round(bitmapHeight) };
}

async imagePreProcessor(sharped, image, _width, config) {
if (this.processed.includes(image) || !this.canProcessImage(config)) {
return sharped;
}
this.processed.push(image);

const { width, height } = await this.getLqipDimensions(config, sharped);
const rawWidth = width * 8;
const rawHeight = height * 8;
const buffer = await sharped.toBuffer();
const lqi = await sharp(buffer)
.ensureAlpha()
.resize(rawWidth, rawHeight, {
fit: 'fill',
})
.raw();

const data = new Uint8ClampedArray(await lqi.toBuffer());
const hash = this.blurhash.encode(data, rawWidth, rawHeight, width, height);

const meta = {
type: 'blurhash',
hash,
width,
height,
};

this.metaData.set(image, meta);

return sharped;
}

addMetaData(image, metadata /*, config*/) {
const ourMeta = this.metaData.get(image);
if (ourMeta) {
metadata.lqip = ourMeta;
}

return metadata;
}
}

module.exports = LqipBlurhashPlugin;
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@
"postpack": "ember ts:clean"
},
"dependencies": {
"@embroider/macros": "^0.36.0",
"@glimmer/component": "^1.0.3",
"@glimmer/tracking": "^1.0.3",
"async-q": "^0.3.1",
"blurhash": "^1.1.3",
"broccoli-caching-writer": "^3.0.3",
"broccoli-funnel": "^2.0.1",
"broccoli-merge-trees": "^4.2.0",
"broccoli-stew": "^3.0.0",
"ember-auto-import": "^1.10.1",
"ember-cli-babel": "^7.23.1",
"ember-cli-htmlbars": "^5.3.1",
"ember-cli-typescript": "^4.1.0",
Expand Down Expand Up @@ -77,7 +80,6 @@
"@typescript-eslint/parser": "^4.14.2",
"babel-eslint": "^10.1.0",
"broccoli-asset-rev": "^3.0.0",
"ember-auto-import": "^1.10.1",
"ember-cli": "~3.24.0",
"ember-cli-app-version": "^4.0.0",
"ember-cli-dependency-checker": "^3.2.0",
Expand Down
4 changes: 4 additions & 0 deletions tests/dummy/app/templates/index.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<h2>LQIP Blurhash</h2>

<ResponsiveImage @image="assets/images/lqip/blurhash.jpg"/>

<h2>LQIP blurry</h2>

<ResponsiveImage @image="assets/images/lqip/inline.jpg"/>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 35 additions & 1 deletion tests/integration/components/responsive-image-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ module('Integration: Responsive Image Component', function (hooks) {
});

module('color', function () {
test('it sets LQIP SVG as background', async function (assert) {
test('it sets background-color', async function (assert) {
let resolve;
const waitUntilLoaded = new Promise((r) => {
resolve = r;
Expand All @@ -422,6 +422,40 @@ module('Integration: Responsive Image Component', function (hooks) {
'after image is loaded the background color is removed'
);
});

module('blurhash', function () {
test('it sets LQIP from blurhash as background', async function (assert) {
let resolve;
const waitUntilLoaded = new Promise((r) => {
resolve = r;
});
this.onload = () => setTimeout(resolve, 0);

await render(
hbs`<ResponsiveImage @image="assets/images/lqip/blurhash.jpg" {{on "load" this.onload}}/>`
);

assert.ok(
this.element
.querySelector('img')
.style.backgroundImage?.match(/data:image\/png/),
'it has a background PNG'
);
assert.dom('img').hasStyle({ 'background-size': 'cover' });
assert.ok(
this.element.querySelector('img').style.backgroundImage?.length >
100,
'the background SVG has a reasonable length'
);

await waitUntilLoaded;

assert.notOk(
this.element.querySelector('img').style.backgroundImage,
'after image is loaded the background PNG is removed'
);
});
});
});
});

Expand Down
Loading

0 comments on commit 908666b

Please sign in to comment.