// @ts-check const assert = require("assert").strict const fs = require("fs") const {pipeline} = require("stream").promises const sharp = require("sharp") const {GIFrame} = require("giframe") const {PNG} = require("pngjs") const utils = require("./utils") const fetch = require("node-fetch").default const streamMimeType = require("stream-mime-type") const SIZE = 48 const RESULT_WIDTH = 400 const IMAGES_ACROSS = Math.floor(RESULT_WIDTH / SIZE) /** * Composite a bunch of Matrix emojis into a kind of spritesheet image to upload to Discord. * @param {string[]} mxcs mxc URLs, in order * @param {(mxc: string) => Promise} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock. * @returns {Promise} PNG image */ async function compositeMatrixEmojis(mxcs, mxcDownloader) { const buffers = await Promise.all(mxcs.map(mxcDownloader)) // Calculate the size of the final composited image const totalWidth = Math.min(buffers.length, IMAGES_ACROSS) * SIZE const imagesDown = Math.ceil(buffers.length / IMAGES_ACROSS) const totalHeight = imagesDown * SIZE const comp = [] let left = 0, top = 0 for (const buffer of buffers) { if (Buffer.isBuffer(buffer)) { // Composite the current buffer into the sprite sheet comp.push({left, top, input: buffer}) // The next buffer should be placed one slot to the right left += SIZE // If we're out of space to fit the entire next buffer there, wrap to the next line if (left + SIZE > RESULT_WIDTH) { left = 0 top += SIZE } } } const output = await sharp({create: {width: totalWidth, height: totalHeight, channels: 4, background: {r: 0, g: 0, b: 0, alpha: 0}}}) .composite(comp) .png() .toBuffer({resolveWithObject: true}) return output.data } /** * Downloads the emoji from the web and converts to uncompressed PNG data. * @param {string} mxc a single mxc:// URL * @returns {Promise} uncompressed PNG data, or undefined if the downloaded emoji is not valid */ async function getAndConvertEmoji(mxc) { const abortController = new AbortController() const url = utils.getPublicUrlForMxc(mxc) assert(url) /** @type {import("node-fetch").Response} */ // If it turns out to be a GIF, we want to abandon the connection without downloading the whole thing. // If we were using connection pooling, we would be forced to download the entire GIF. // So we set no agent to ensure we are not connection pooling. // @ts-ignore the signal is slightly different from the type it wants (still works fine) const res = await fetch(url, {agent: false, signal: abortController.signal}) return convertImageStream(res.body, () => { abortController.abort() res.body.pause() res.body.emit("end") }) } /** * MOCK: Gets the emoji from the filesystem and converts to uncompressed PNG data. * @param {string} mxc a single mxc:// URL * @returns {Promise} uncompressed PNG data, or undefined if the downloaded emoji is not valid */ async function _mockGetAndConvertEmoji(mxc) { const id = mxc.match(/\/([^./]*)$/)?.[1] let s if (fs.existsSync(`test/res/${id}.png`)) { s = fs.createReadStream(`test/res/${id}.png`) } else { s = fs.createReadStream(`test/res/${id}.gif`) } return convertImageStream(s, () => { s.pause() s.emit("end") }) } /** * @param {import("node-fetch").Response["body"]} streamIn * @param {() => any} stopStream * @returns {Promise} Uncompressed PNG image */ async function convertImageStream(streamIn, stopStream) { const {stream, mime} = await streamMimeType.getMimeType(streamIn) assert(["image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"].includes(mime), `Mime type ${mime} is impossible for emojis`) try { if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp") { /** @type {{info: sharp.OutputInfo, buffer: Buffer}} */ const result = await new Promise((resolve, reject) => { const transformer = sharp() .resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}}) .png({compressionLevel: 0}) .toBuffer((err, buffer, info) => { /* c8 ignore next */ if (err) return reject(err) resolve({info, buffer}) }) pipeline( stream, transformer ) }) return result.buffer } else if (mime === "image/gif") { const giframe = new GIFrame(0) stream.on("data", chunk => { giframe.feed(chunk) }) const frame = await giframe.getFrame() stopStream() const buffer = await sharp(frame.pixels, {raw: {width: frame.width, height: frame.height, channels: 4}}) .resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}}) .png({compressionLevel: 0}) .toBuffer({resolveWithObject: true}) return buffer.data } else if (mime === "image/apng") { const png = new PNG({maxFrames: 1}) // @ts-ignore stream.pipe(png) /** @type {Buffer} */ // @ts-ignore const frame = await new Promise(resolve => png.on("parsed", resolve)) stopStream() const buffer = await sharp(frame, {raw: {width: png.width, height: png.height, channels: 4}}) .resize(SIZE, SIZE, {fit: "contain", background: {r: 0, g: 0, b: 0, alpha: 0}}) .png({compressionLevel: 0}) .toBuffer({resolveWithObject: true}) return buffer.data } } finally { stopStream() } } module.exports.compositeMatrixEmojis = compositeMatrixEmojis module.exports.getAndConvertEmoji = getAndConvertEmoji module.exports._mockGetAndConvertEmoji = _mockGetAndConvertEmoji module.exports._convertImageStream = convertImageStream