UNPKG

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
"use strict"; 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;