2023-09-23 13:55:47 +00:00
|
|
|
// @ts-check
|
|
|
|
|
|
|
|
const assert = require("assert").strict
|
2024-03-01 04:28:14 +00:00
|
|
|
const fs = require("fs")
|
2023-09-23 13:55:47 +00:00
|
|
|
const {pipeline} = require("stream").promises
|
|
|
|
const sharp = require("sharp")
|
|
|
|
const {GIFrame} = require("giframe")
|
2024-02-01 03:38:17 +00:00
|
|
|
const {PNG} = require("pngjs")
|
2023-09-23 13:55:47 +00:00
|
|
|
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
|
2024-03-01 04:28:14 +00:00
|
|
|
* @param {(mxc: string) => Promise<Buffer | undefined>} mxcDownloader function that will download the mxc URLs and convert to uncompressed PNG data. use `getAndConvertEmoji` or a mock.
|
2023-09-23 13:55:47 +00:00
|
|
|
* @returns {Promise<Buffer>} PNG image
|
|
|
|
*/
|
2024-03-01 04:28:14 +00:00
|
|
|
async function compositeMatrixEmojis(mxcs, mxcDownloader) {
|
|
|
|
const buffers = await Promise.all(mxcs.map(mxcDownloader))
|
2023-09-23 13:55:47 +00:00
|
|
|
|
2023-10-07 09:47:31 +00:00
|
|
|
// Calculate the size of the final composited image
|
2023-09-23 13:55:47 +00:00
|
|
|
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)) {
|
2023-10-07 09:47:31 +00:00
|
|
|
// Composite the current buffer into the sprite sheet
|
2023-09-23 13:55:47 +00:00
|
|
|
comp.push({left, top, input: buffer})
|
2023-10-07 09:47:31 +00:00
|
|
|
// 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
|
|
|
|
}
|
2023-09-23 13:55:47 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-03-01 04:28:14 +00:00
|
|
|
/**
|
|
|
|
* Downloads the emoji from the web and converts to uncompressed PNG data.
|
|
|
|
* @param {string} mxc a single mxc:// URL
|
|
|
|
* @returns {Promise<Buffer | undefined>} 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<Buffer | undefined>} 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")
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2024-02-01 03:38:17 +00:00
|
|
|
/**
|
|
|
|
* @param {import("node-fetch").Response["body"]} streamIn
|
|
|
|
* @param {() => any} stopStream
|
|
|
|
* @returns {Promise<Buffer | undefined>} 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()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-23 13:55:47 +00:00
|
|
|
module.exports.compositeMatrixEmojis = compositeMatrixEmojis
|
2024-03-01 04:28:14 +00:00
|
|
|
module.exports.getAndConvertEmoji = getAndConvertEmoji
|
|
|
|
module.exports._mockGetAndConvertEmoji = _mockGetAndConvertEmoji
|
2024-02-01 03:38:17 +00:00
|
|
|
module.exports._convertImageStream = convertImageStream
|