adonis-responsive-attachment
Version:
Generate and persist optimised and responsive breakpoint images on the fly in your AdonisJS application.
313 lines (312 loc) • 12.7 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectHEIC = exports.encodeImageToBlurhash = exports.getDefaultBlurhashOptions = exports.generateBreakpointImages = exports.generateThumbnail = exports.optimize = exports.generateName = exports.generateBreakpoint = exports.canBeProcessed = exports.allowedFormats = exports.breakpointSmallerThan = exports.resizeTo = exports.THUMBNAIL_RESIZE_OPTIONS = exports.getDimensions = exports.getMetaData = exports.bytesToKBytes = exports.getMergedOptions = void 0;
const sharp_1 = __importDefault(require("sharp"));
const helpers_1 = require("@poppinss/utils/build/helpers");
const lodash_1 = require("lodash");
const decorator_1 = require("../Attachment/decorator");
const blurhash_1 = require("blurhash");
const getMergedOptions = function (options) {
return (0, lodash_1.merge)({
preComputeUrls: false,
breakpoints: decorator_1.DEFAULT_BREAKPOINTS,
forceFormat: undefined,
optimizeOrientation: true,
optimizeSize: true,
responsiveDimensions: true,
disableThumbnail: false,
blurhash: getDefaultBlurhashOptions(options),
keepOriginal: true,
persistentFileNames: false,
}, options);
};
exports.getMergedOptions = getMergedOptions;
const bytesToKBytes = (bytes) => Math.round((bytes / 1000) * 100) / 100;
exports.bytesToKBytes = bytesToKBytes;
const getMetaData = async (buffer) => await (0, sharp_1.default)(buffer, { failOnError: false }).metadata();
exports.getMetaData = getMetaData;
const getDimensions = async function (buffer) {
return await (0, exports.getMetaData)(buffer).then(({ width, height }) => ({ width, height }));
};
exports.getDimensions = getDimensions;
/**
* Default thumbnail resize options
*/
exports.THUMBNAIL_RESIZE_OPTIONS = {
width: 245,
height: 156,
fit: 'inside',
};
const resizeTo = async function (buffer, options, resizeOptions) {
const sharpInstance = options?.forceFormat
? (0, sharp_1.default)(buffer, { failOnError: false }).toFormat(options.forceFormat)
: (0, sharp_1.default)(buffer, { failOnError: false });
return await sharpInstance
.withMetadata()
.resize(resizeOptions)
.toBuffer()
.catch(() => null);
};
exports.resizeTo = resizeTo;
const breakpointSmallerThan = (breakpoint, { width, height }) => breakpoint < width || breakpoint < height;
exports.breakpointSmallerThan = breakpointSmallerThan;
exports.allowedFormats = [
'jpeg',
'png',
'webp',
'avif',
'tiff',
'heif',
];
const canBeProcessed = async (buffer) => {
const { format } = await (0, exports.getMetaData)(buffer);
return format && exports.allowedFormats.includes(format);
};
exports.canBeProcessed = canBeProcessed;
const getImageExtension = function (imageFormat) {
return imageFormat === 'jpeg' ? 'jpg' : imageFormat;
};
const generateBreakpoint = async ({ key, imageData, breakpoint, options, }) => {
const breakpointBuffer = await (0, exports.resizeTo)(imageData.buffer, options, {
width: breakpoint,
height: breakpoint,
fit: 'inside',
});
if (breakpointBuffer) {
const { width, height, size, format } = await (0, exports.getMetaData)(breakpointBuffer);
const extname = getImageExtension(format);
const breakpointFileName = (0, exports.generateName)({
extname,
options,
prefix: key,
fileName: imageData.fileName,
});
return {
key: key,
file: {
// Override attributes in `imageData`
name: breakpointFileName,
extname,
mimeType: `image/${format}`,
format: format,
width: width,
height: height,
size: (0, exports.bytesToKBytes)(size),
buffer: breakpointBuffer,
blurhash: imageData.blurhash,
},
};
}
else {
return null;
}
};
exports.generateBreakpoint = generateBreakpoint;
/**
* Generates the name for the attachment and prefixes
* the folder (if defined)
* @param payload
* @param payload.extname The extension name for the image
* @param payload.hash Hash string to use instead of a CUID
* @param payload.prefix String to prepend to the filename
* @param payload.options Attachment options
*/
const generateName = function ({ extname, fileName, hash, prefix, options, }) {
const usePersistentFileNames = options?.persistentFileNames ?? false;
hash = usePersistentFileNames ? '' : (hash ?? (0, helpers_1.cuid)());
return `${options?.folder ? `${options.folder}/` : ''}${prefix}${fileName ? `_${fileName}` : ''}${hash ? `_${hash}` : ''}.${extname}`;
};
exports.generateName = generateName;
const optimize = async function (buffer, options) {
const { optimizeOrientation, optimizeSize, forceFormat } = options || {};
// Check if the image is in the right format or can be size optimised
if (!optimizeSize || !(await (0, exports.canBeProcessed)(buffer))) {
return { buffer };
}
// Auto rotate the image if `optimizeOrientation` is true
let sharpInstance = optimizeOrientation
? (0, sharp_1.default)(buffer, { failOnError: false }).rotate()
: (0, sharp_1.default)(buffer, { failOnError: false });
// Force image to output to a specific format if `forceFormat` is true
sharpInstance = forceFormat ? sharpInstance.toFormat(forceFormat) : sharpInstance;
return await sharpInstance
.toBuffer({ resolveWithObject: true })
.then(({ data, info }) => {
// The original buffer should not be smaller than the optimised buffer
const outputBuffer = buffer.length < data.length ? buffer : data;
return {
buffer: outputBuffer,
info: {
width: info.width,
height: info.height,
size: (0, exports.bytesToKBytes)(outputBuffer.length),
format: info.format,
mimeType: `image/${info.format}`,
extname: getImageExtension(info.format),
},
};
})
.catch(() => ({ buffer }));
};
exports.optimize = optimize;
const generateThumbnail = async function (imageData, options) {
options = (0, exports.getMergedOptions)(options);
const blurhashEnabled = !!options.blurhash?.enabled;
let blurhash;
if (!(await (0, exports.canBeProcessed)(imageData.buffer))) {
return null;
}
if (!blurhashEnabled && (!options?.responsiveDimensions || options?.disableThumbnail)) {
return null;
}
const { width, height } = await (0, exports.getDimensions)(imageData.buffer);
if (!width || !height)
return null;
if (width > exports.THUMBNAIL_RESIZE_OPTIONS.width || height > exports.THUMBNAIL_RESIZE_OPTIONS.height) {
const thumbnailBuffer = await (0, exports.resizeTo)(imageData.buffer, options, exports.THUMBNAIL_RESIZE_OPTIONS);
if (thumbnailBuffer) {
const { width: thumbnailWidth, height: thumbnailHeight, size, format, } = await (0, exports.getMetaData)(thumbnailBuffer);
const extname = getImageExtension(format);
const thumbnailFileName = (0, exports.generateName)({
extname,
options,
prefix: 'thumbnail',
fileName: imageData.fileName,
});
const thumbnailImageData = {
name: thumbnailFileName,
extname,
mimeType: `image/${format}`,
format: format,
width: thumbnailWidth,
height: thumbnailHeight,
size: (0, exports.bytesToKBytes)(size),
buffer: thumbnailBuffer,
};
// Generate blurhash
if (blurhashEnabled) {
blurhash = await encodeImageToBlurhash(options, thumbnailImageData.buffer);
// Set the blurhash in the thumbnail data
thumbnailImageData.blurhash = blurhash;
}
return thumbnailImageData;
}
}
return null;
};
exports.generateThumbnail = generateThumbnail;
const generateBreakpointImages = async function (imageData, options) {
options = (0, exports.getMergedOptions)(options);
/**
* Noop if `responsiveDimensions` is falsy
*/
if (!options.responsiveDimensions)
return [];
/**
* Noop if image format is not allowed
*/
if (!(await (0, exports.canBeProcessed)(imageData.buffer))) {
return [];
}
const originalDimensions = await (0, exports.getDimensions)(imageData.buffer);
const activeBreakpoints = (0, lodash_1.pickBy)(options.breakpoints, (value) => {
return value !== 'off';
});
if ((0, lodash_1.isEmpty)(activeBreakpoints))
return [];
return Promise.all(Object.keys(activeBreakpoints).map((key) => {
const breakpointValue = options.breakpoints?.[key];
const isBreakpointSmallerThanOriginal = (0, exports.breakpointSmallerThan)(breakpointValue, originalDimensions);
if (isBreakpointSmallerThanOriginal) {
return (0, exports.generateBreakpoint)({ key, imageData, breakpoint: breakpointValue, options });
}
}));
};
exports.generateBreakpointImages = generateBreakpointImages;
function getDefaultBlurhashOptions(options) {
return {
enabled: options?.blurhash?.enabled ?? false,
componentX: options?.blurhash?.componentX ?? 4,
componentY: options?.blurhash?.componentY ?? 3,
};
}
exports.getDefaultBlurhashOptions = getDefaultBlurhashOptions;
function encodeImageToBlurhash(options, imageBuffer) {
const { blurhash } = options;
const { componentX, componentY } = blurhash || {};
if (!componentX || !componentY) {
throw new Error('[Adonis Responsive Attachment] Ensure "componentX" and "componentY" are set');
}
if (!imageBuffer) {
throw new Error('[Adonis Responsive Attachment] Ensure "buffer" is provided');
}
return new Promise(async (resolve, reject) => {
try {
// Convert buffer to pixels
const { data: pixels, info: metadata } = await (0, sharp_1.default)(imageBuffer)
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
return resolve((0, blurhash_1.encode)(new Uint8ClampedArray(pixels), metadata.width, metadata.height, componentX, componentY));
}
catch (error) {
return reject(error);
}
});
}
exports.encodeImageToBlurhash = encodeImageToBlurhash;
const detectHEIC = (buffer) => {
// Need at least 12 bytes to check ftyp + brand
if (buffer.length < 12) {
return false;
}
// Bytes 0–3 start with 00 00 00 (box size)
if (!(buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0x00)) {
return false;
}
// Bytes 4–8 must be "ftyp"
if (buffer.toString('ascii', 4, 8) !== 'ftyp') {
return false;
}
const brand = buffer.toString('ascii', 8, 12);
const IMAGE_BRANDS = ['heic', 'heix'];
const MULTI_IMAGE_BRANDS = ['mif1', 'msf1'];
const VIDEO_BRANDS = ['hevc', 'hevx'];
// Reject outright if it’s a video-brand HEIF
if (VIDEO_BRANDS.includes(brand)) {
return false;
}
// Helper: scan for 'hdlr' boxes and their handler_type
const hasHandlerType = (type) => {
const tag = Buffer.from('hdlr');
let idx = buffer.indexOf(tag);
while (idx !== -1) {
// handler_type is 8 bytes after the 'hdlr' tag
const handler = buffer.toString('ascii', idx + 8, idx + 12);
if (handler === type) {
return true;
}
idx = buffer.indexOf(tag, idx + 1);
}
return false;
};
// If it has a 'vide' handler, it’s a video HEIF → reject
if (hasHandlerType('vide')) {
return false;
}
// Now accept only still-image HEIF brands (or multi-image)
if (IMAGE_BRANDS.includes(brand) || MULTI_IMAGE_BRANDS.includes(brand)) {
// optionally you could double-check that there's at least one 'pict' handler
// if (!hasHandlerType('pict')) return false
return {
ext: 'heif',
mime: 'image/heif',
};
}
// Anything else falls back to false
return false;
};
exports.detectHEIC = detectHEIC;