UNPKG

sharp-apng

Version:

APNG(animated PNG) encoder and decoder for sharp base on upng-js.

239 lines (218 loc) 6.45 kB
const fs = require("fs"); const sharp = require("sharp"); const { GIFEncoder, quantize, applyPalette } = require("gifenc"); const UPNG = require("upng-js"); /** * Cut frames from animated sharp */ async function getFrames(image) { const { pages, width, pageHeight } = await image.metadata(); const frames = []; if (pages > 1) { image = sharp(await image.png().toBuffer()); for (let i = 0; i < pages; i++) { const frame = image.clone().extract({ left: 0, top: pageHeight * i, width: width, height: pageHeight, }); frames.push(sharp(await frame.toBuffer())); } } else { frames.push(image); } return frames; } /** * Decode APNG image */ function decodeApng(input) { const buffer = typeof input === "string" ? fs.readFileSync(input) : input; const decoder = UPNG.decode(buffer); const { width, height, depth, ctype } = decoder; const delay = decoder.frames.map((frame) => frame.delay); const frames = UPNG.toRGBA8(decoder).map((frame) => Buffer.from(frame)); return { width, height, depth, ctype, delay, pages: frames.length, frames }; } /** * Encode animated GIF * sharp does not support APNG encoding, * nor creates animated instance from frames, * so we have to convert to GIF encoded buffer. */ function encodeGif(frames, options) { let { width, height, delay = [], repeat = 0, transparent = false, maxColors = 256, format = "rgb565", gifEncoderOptions = {}, gifEncoderQuantizeOptions = {}, gifEncoderFrameOptions = {}, } = options; if (typeof delay === "number") { delay = new Array(frames.length).fill(delay); } if (repeat === 1) { repeat = -1; } const encoder = GIFEncoder(gifEncoderOptions); // Write out frames frames.forEach((frame, i) => { const data = new Uint8ClampedArray(frame); const palette = quantize(data, maxColors, { format, ...gifEncoderQuantizeOptions, }); const index = applyPalette(data, palette, format); encoder.writeFrame(index, width, height, { transparent, delay: delay[i], repeat, ...gifEncoderFrameOptions, palette, }); }); // Write out footer bytes. encoder.finish(); return encoder.bytes(); } /** * Create instances of sharp from APNG frames. */ function framesFromApng(input, resolveWithObject = false) { const apng = decodeApng(input); const frames = apng.frames.map((frame) => { return sharp(frame, { raw: { width: apng.width, height: apng.height, channels: 4, }, }); }); return resolveWithObject ? { ...apng, frames } : frames; } /** * Create an instance of animated sharp from an APNG image */ async function sharpFromApng(input, options = {}, resolveWithObject = false) { const apng = decodeApng(input); const gifBuffer = encodeGif(apng.frames, { width: apng.width, height: apng.height, ...options, }); const image = sharp(gifBuffer, { animated: true, ...options.sharpOptions, }).gif({ loop: options.repeat || 0, delay: options.delay || apng.delay, }); return resolveWithObject ? { ...apng, image } : image; } /** * Write an APNG file from an array of instances of sharp */ async function framesToApng(images, fileOut, options = {}) { let { width, height, cnum = 0, delay: oDelay = [], resizeTo = "largest", resizeType = "zoom", resizeOptions = {}, extendBackground = { r: 0, g: 0, b: 0, alpha: 0 }, rawOptions, } = options; if (typeof oDelay === "number") { oDelay = new Array(images.length).fill(oDelay); } const bufs = []; const dels = []; const cutted = []; // Get width and height of output gif let meta; if (!width || !height) { meta = await Promise.all(images.map((frame) => frame.metadata())); const math = resizeTo === "largest" ? Math.max : Math.min; width = width || math(...meta.map((m) => m.width)); height = height || math(...meta.map((m) => m.pageHeight || m.height)); } // Parse frames for (let i = 0; i < images.length; i++) { const frame = images[i]; const { pages, delay } = meta?.[i] || (await frame.metadata()); if (pages > 1) { const frames = await getFrames(frame); cutted.push(...frames); dels.push(...delay); } else { cutted.push(frame); dels.push(oDelay[i] || 0); } } // Get frames buffer for (let i = 0; i < cutted.length; i++) { const frame = cutted[i]; const { width: frameWidth, height: frameHeight } = await frame.metadata(); if (frameWidth !== width || frameHeight !== height) { // Resize frame if (resizeType === "zoom") { frame.resize({ ...resizeOptions, width, height, }); } // Extend or extract frame else { const halfWidth = Math.abs(width - frameWidth) / 2; if (frameWidth < width) { frame.extend({ left: halfWidth, right: halfWidth, background: extendBackground, }); } else if (frameWidth > width) { frame.extract({ left: halfWidth, top: 0, width, height }); } const halfHeight = Math.abs(height - frameHeight) / 2; if (frameHeight < height) { frame.extend({ top: halfHeight, bottom: halfHeight, background: extendBackground, }); } else if (frameHeight > height) { frame.extract({ left: 0, top: halfHeight, width, height }); } } } const { buffer } = await frame.ensureAlpha().raw(rawOptions).toBuffer(); bufs.push(buffer); } const buffer = Buffer.from(UPNG.encode(bufs, width, height, cnum, dels)); fs.writeFileSync(fileOut, buffer); return { width, height, size: buffer.length }; } /** * Write an APNG file from an animated sharp */ async function sharpToApng(image, fileOut, options = {}) { const frames = await getFrames(image); const { delay } = await image.metadata(); return framesToApng(frames, fileOut, { delay, ...options }); } module.exports = { framesFromApng, sharpFromApng, framesToApng, sharpToApng, };