'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;