1
0
Fork 0
out-of-your-element-fork-th.../m2d/converters/emoji-sheet.js

162 lines
5.5 KiB
JavaScript
Raw Normal View History

// @ts-check
const assert = require("assert").strict
2024-03-01 04:28:14 +00:00
const fs = require("fs")
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")
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.
* @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-10-07 09:47:31 +00:00
// 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)) {
2023-10-07 09:47:31 +00:00
// Composite the current buffer into the sprite sheet
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
}
}
}
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()
}
}
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