Source: util/Util.js

'use strict';

const path = require('path');
const Crypto = require('crypto');
const { spawn } = require('child_process');
const { tmpdir } = require('os');
const webp = require('node-webpmux');
const fs = require('fs').promises;
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
let ffmpegPath = 'ffmpeg';

const runFfmpeg = (input, inputFormat, outputPath) =>
    new Promise((resolve, reject) => {
        const stderr = [];
        let stderrBytes = 0;
        const ffmpegProcess = spawn(
            ffmpegPath,
            [
                '-f',
                inputFormat,
                '-i',
                'pipe:0',
                '-vcodec',
                'libwebp',
                '-vf',
                "scale='iw*min(300/iw,300/ih)':'ih*min(300/iw,300/ih)',format=rgba,pad=300:300:'(300-iw)/2':'(300-ih)/2':'#00000000',setsar=1,fps=10",
                '-loop',
                '0',
                '-ss',
                '00:00:00.0',
                '-t',
                '00:00:05.0',
                '-preset',
                'default',
                '-an',
                '-vsync',
                '0',
                '-s',
                '512:512',
                '-f',
                'webp',
                '-y',
                outputPath,
            ],
            { stdio: ['pipe', 'ignore', 'pipe'] },
        );

        ffmpegProcess.stderr.on('data', (chunk) => {
            if (stderrBytes >= 8192) return;
            const remaining = 8192 - stderrBytes;
            stderr.push(chunk.subarray(0, remaining));
            stderrBytes += Math.min(chunk.length, remaining);
        });
        ffmpegProcess.once('error', reject);
        ffmpegProcess.once('close', (code, signal) => {
            if (code === 0) {
                resolve();
                return;
            }
            const detail = Buffer.concat(stderr).toString('utf8').trim();
            const error = new Error(
                `FFmpeg exited with ${signal ? `signal ${signal}` : `code ${code}`}${detail ? `: ${detail}` : ''}`,
            );
            error.code = 'wwebjs_ffmpeg_failed';
            reject(error);
        });
        ffmpegProcess.stdin.on('error', (error) => {
            if (error.code !== 'EPIPE') reject(error);
        });
        ffmpegProcess.stdin.end(input);
    });

/**
 * Utility methods
 */
class Util {
    constructor() {
        throw new Error(
            `The ${this.constructor.name} class may not be instantiated.`,
        );
    }

    static generateHash(length) {
        var result = '';
        var characters =
            'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
        var charactersLength = characters.length;
        for (var i = 0; i < length; i++) {
            result += characters.charAt(
                Math.floor(Math.random() * charactersLength),
            );
        }
        return result;
    }

    /**
     * Sets default properties on an object that aren't already specified.
     * @param {Object} def Default properties
     * @param {Object} given Object to assign defaults to
     * @returns {Object}
     * @private
     */
    static mergeDefault(def, given) {
        if (!given) return def;
        const descriptors = Object.getOwnPropertyDescriptors(given);
        const hasReadOnlyProperty = Object.values(descriptors).some(
            (descriptor) =>
                ('writable' in descriptor && descriptor.writable === false) ||
                ('get' in descriptor && descriptor.set === undefined),
        );
        const target =
            !Object.isExtensible(given) || hasReadOnlyProperty
                ? Array.isArray(given)
                    ? [...given]
                    : { ...given }
                : given;
        for (const key in def) {
            if (!has(target, key) || target[key] === undefined) {
                target[key] = def[key];
            } else if (target[key] === Object(target[key])) {
                const merged = Util.mergeDefault(def[key], target[key]);
                if (merged !== target[key]) target[key] = merged;
            }
        }

        return target;
    }

    /**
     * Formats a image to webp
     * @param {MessageMedia} media
     *
     * @returns {Promise<MessageMedia>} media in webp format
     */
    static async formatImageToWebpSticker(media, pupPage) {
        if (!media.mimetype.includes('image'))
            throw new Error('media is not a image');

        if (media.mimetype.includes('webp')) {
            return media;
        }

        return pupPage.evaluate((media) => {
            return window.WWebJS.toStickerData(media);
        }, media);
    }

    /**
     * Formats a video to webp
     * @param {MessageMedia} media
     *
     * @returns {Promise<MessageMedia>} media in webp format
     */
    static async formatVideoToWebpSticker(media) {
        if (!media.mimetype.includes('video'))
            throw new Error('media is not a video');

        const videoType = media.mimetype.split('/')[1];

        const tempFile = path.join(
            tmpdir(),
            `${Crypto.randomBytes(6).readUIntLE(0, 6).toString(36)}.webp`,
        );

        const buffer = Buffer.from(
            media.data.replace(`data:${media.mimetype};base64,`, ''),
            'base64',
        );
        try {
            await runFfmpeg(buffer, videoType, tempFile);
            const data = await fs.readFile(tempFile, 'base64');

            return {
                mimetype: 'image/webp',
                data,
                filename: media.filename,
            };
        } finally {
            await fs.rm(tempFile, { force: true });
        }
    }

    /**
     * Sticker metadata.
     * @typedef {Object} StickerMetadata
     * @property {string} [name]
     * @property {string} [author]
     * @property {string[]} [categories]
     */

    /**
     * Formats a media to webp
     * @param {MessageMedia} media
     * @param {StickerMetadata} metadata
     *
     * @returns {Promise<MessageMedia>} media in webp format
     */
    static async formatToWebpSticker(media, metadata, pupPage) {
        let webpMedia;

        if (media.mimetype.includes('image'))
            webpMedia = await this.formatImageToWebpSticker(media, pupPage);
        else if (media.mimetype.includes('video'))
            webpMedia = await this.formatVideoToWebpSticker(media);
        else throw new Error('Invalid media format');

        if (metadata.name || metadata.author) {
            const img = new webp.Image();
            const hash = this.generateHash(32);
            const stickerPackId = hash;
            const packname = metadata.name;
            const author = metadata.author;
            const categories = metadata.categories || [''];
            const json = {
                'sticker-pack-id': stickerPackId,
                'sticker-pack-name': packname,
                'sticker-pack-publisher': author,
                emojis: categories,
            };
            let exifAttr = Buffer.from([
                0x49, 0x49, 0x2a, 0x00, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00,
                0x41, 0x57, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x00,
                0x00, 0x00,
            ]);
            let jsonBuffer = Buffer.from(JSON.stringify(json), 'utf8');
            let exif = Buffer.concat([exifAttr, jsonBuffer]);
            exif.writeUIntLE(jsonBuffer.length, 14, 4);
            await img.load(Buffer.from(webpMedia.data, 'base64'));
            img.exif = exif;
            webpMedia.data = (await img.save(null)).toString('base64');
        }

        return webpMedia;
    }

    /**
     * Configure ffmpeg path
     * @param {string} path
     */
    static setFfmpegPath(path) {
        ffmpegPath = path;
    }
}

module.exports = Util;