diff options
| author | Pitu <[email protected]> | 2021-01-04 01:04:20 +0900 |
|---|---|---|
| committer | Pitu <[email protected]> | 2021-01-04 01:04:20 +0900 |
| commit | fcd39dc550dec8dbcb8325e07e938c5024cbc33d (patch) | |
| tree | f41acb4e0d5fd3c3b1236fe4324b3fef9ec6eafe /src/api/utils | |
| parent | Create FUNDING.yml (diff) | |
| parent | chore: update todo (diff) | |
| download | host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.tar.xz host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.zip | |
Merge branch 'dev'
Diffstat (limited to 'src/api/utils')
| -rw-r--r-- | src/api/utils/Log.js | 36 | ||||
| -rw-r--r-- | src/api/utils/QueryHelper.js | 200 | ||||
| -rw-r--r-- | src/api/utils/ThumbUtil.js | 104 | ||||
| -rw-r--r-- | src/api/utils/Util.js | 296 | ||||
| -rw-r--r-- | src/api/utils/generateThumbs.js | 17 | ||||
| -rw-r--r-- | src/api/utils/videoPreview/FragmentPreview.js | 88 | ||||
| -rw-r--r-- | src/api/utils/videoPreview/FrameIntervalPreview.js | 73 |
7 files changed, 814 insertions, 0 deletions
diff --git a/src/api/utils/Log.js b/src/api/utils/Log.js new file mode 100644 index 0000000..9a5efc9 --- /dev/null +++ b/src/api/utils/Log.js @@ -0,0 +1,36 @@ +const chalk = require('chalk'); +const { dump } = require('dumper.js'); + +class Log { + static info(args) { + if (Log.checkIfArrayOrObject(args)) dump(args); + else console.log(args); // eslint-disable-line no-console + } + + static success(args) { + if (Log.checkIfArrayOrObject(args)) dump(args); + else console.log(chalk.green(args)); // eslint-disable-line no-console + } + + static warn(args) { + if (Log.checkIfArrayOrObject(args)) dump(args); + else console.log(chalk.yellow(args)); // eslint-disable-line no-console + } + + static error(args) { + if (Log.checkIfArrayOrObject(args)) dump(args); + else console.log(chalk.red(args)); // eslint-disable-line no-console + } + + static debug(args) { + if (Log.checkIfArrayOrObject(args)) dump(args); + else console.log(chalk.gray(args)); // eslint-disable-line no-console + } + + static checkIfArrayOrObject(thing) { + if (typeof thing === typeof [] || typeof thing === typeof {}) return true; + return false; + } +} + +module.exports = Log; diff --git a/src/api/utils/QueryHelper.js b/src/api/utils/QueryHelper.js new file mode 100644 index 0000000..c26c8eb --- /dev/null +++ b/src/api/utils/QueryHelper.js @@ -0,0 +1,200 @@ +const chrono = require('chrono-node'); + +class QueryHelper { + static parsers = { + before: val => QueryHelper.parseChronoList(val), + after: val => QueryHelper.parseChronoList(val), + tag: val => QueryHelper.sanitizeTags(val) + }; + + static requirementHandlers = { + album: knex => knex + .join('albumsFiles', 'files.id', '=', 'albumsFiles.fileId') + .join('albums', 'albumsFiles.albumId', '=', 'album.id'), + tag: knex => knex + .join('fileTags', 'files.id', '=', 'fileTags.fileId') + .join('tags', 'fileTags.tagId', '=', 'tags.id') + } + + static fieldToSQLMapping = { + album: 'albums.name', + tag: 'tags.name', + before: 'files.createdAt', + after: 'files.createdAt' + } + + static handlers = { + album({ db, knex }, list) { + return QueryHelper.generateInclusionForAlbums(db, knex, list); + }, + tag({ db, knex }, list) { + list = QueryHelper.parsers.tag(list); + return QueryHelper.generateInclusionForTags(db, knex, list); + }, + before({ knex }, list) { + list = QueryHelper.parsers.before(list); + return QueryHelper.generateBefore(knex, 'before', list); + }, + after({ knex }, list) { + list = QueryHelper.parsers.after(list); + return QueryHelper.generateAfter(knex, 'after', list); + }, + file({ knex }, list) { + return QueryHelper.generateLike(knex, 'name', list); + }, + exclude({ db, knex }, dict) { + for (const [key, value] of Object.entries(dict)) { + if (key === 'album') { + knex = QueryHelper.generateExclusionForAlbums(db, knex, value); + } + if (key === 'tag') { + const parsed = QueryHelper.parsers.tag(value); + knex = QueryHelper.generateExclusionForTags(db, knex, parsed); + } + } + return knex; + } + } + + static verify(field, list) { + if (!Array.isArray(list)) { + throw new Error(`Expected Array got ${typeof list}`); + } + if (typeof field !== 'string') { + throw new Error(`Expected string got ${typeof field}`); + } + return true; + } + + static getMapping(field) { + if (!QueryHelper.fieldToSQLMapping[field]) { + throw new Error(`No SQL mapping for ${field} field found`); + } + + return QueryHelper.fieldToSQLMapping[field]; + } + + static generateIn(knex, field, list) { + QueryHelper.verify(field, list); + return knex.whereIn(QueryHelper.getMapping(field), list); + } + + static generateNotIn(knex, field, list) { + QueryHelper.verify(field, list); + return knex.whereNotExists(QueryHelper.getMapping(field), list); + } + + static generateBefore(knex, field, list) { + QueryHelper.verify(field, list); + } + + static generateAfter(knex, field, list) { + QueryHelper.verify(field, list); + } + + static parseChronoList(list) { + return list.map(e => chrono.parse(e)); + } + + static sanitizeTags(list) { + return list.map(e => e.replace(/\s/g, '_')); + } + + static generateInclusionForTags(db, knex, list) { + const subQ = db.table('fileTags') + .select('fileTags.fileId') + .join('tags', 'fileTags.tagId', '=', 'tags.id') + .where('fileTags.fileId', db.ref('files.id')) + .whereIn('tags.name', list) + .groupBy('fileTags.fileId') + .havingRaw('count(distinct tags.name) = ?', [list.length]); + + return knex.whereIn('files.id', subQ); + } + + static generateInclusionForAlbums(db, knex, list) { + const subQ = db.table('albumsFiles') + .select('albumsFiles.fileId') + .join('albums', 'albumsFiles.albumId', '=', 'albums.id') + .where('albumsFiles.fileId', db.ref('files.id')) + .whereIn('albums.name', list) + .groupBy('albumsFiles.fileId') + .havingRaw('count(distinct albums.name) = ?', [list.length]); + + return knex.whereIn('files.id', subQ); + } + + static generateExclusionForTags(db, knex, list) { + const subQ = db.table('fileTags') + .select('fileTags.fileId') + .join('tags', 'fileTags.tagId', '=', 'tags.id') + .where('fileTags.fileId', db.ref('files.id')) + .whereIn('tags.name', list); + + return knex.whereNotIn('files.id', subQ); + } + + static generateExclusionForAlbums(db, knex, list) { + const subQ = db.table('albumsFiles') + .select('albumsFiles.fileId') + .join('albums', 'albumsFiles.albumId', '=', 'albums.id') + .where('albumsFiles.fileId', db.ref('files.id')) + .whereIn('albums.name', list); + + return knex.whereNotIn('files.id', subQ); + } + + static generateLike(knex, field, list) { + for (const str of list) { + knex = knex.where(field, 'like', `${str}%`); + } + + return knex; + } + + static loadRequirements(knex, queryObject) { + // sanity check so we don't accidentally require the same thing twice + const loadedRequirements = []; + + for (const key of Object.keys(queryObject)) { + if (QueryHelper.requirementHandlers[key] && loadedRequirements.indexOf(key) === -1) { + knex = QueryHelper.requirementHandlers[key](knex); + loadedRequirements.push(key); + } + } + + return knex; + } + + static mergeTextWithTags(queryObject) { + if (queryObject.text) { + let { text } = queryObject; + if (!Array.isArray(text)) { text = [text]; } + + queryObject.tag = [...(queryObject.tag || []), ...text]; + } + + if (queryObject.exclude && queryObject.exclude.text) { + let { text } = queryObject.exclude; + if (!Array.isArray(text)) { text = [text]; } + + queryObject.exclude.tag = [...(queryObject.exclude.tag || []), ...text]; + } + + return queryObject; + } + + static processQuery(db, knex, queryObject) { + queryObject = QueryHelper.mergeTextWithTags(queryObject); + // knex = QueryHelper.loadRequirements(knex, queryObject); + for (const [key, value] of Object.entries(queryObject)) { + if (QueryHelper.handlers[key]) { + knex = QueryHelper.handlers[key]({ db, knex }, value); + } + } + + return knex; + } +} + +module.exports = QueryHelper; diff --git a/src/api/utils/ThumbUtil.js b/src/api/utils/ThumbUtil.js new file mode 100644 index 0000000..d08ecab --- /dev/null +++ b/src/api/utils/ThumbUtil.js @@ -0,0 +1,104 @@ +const jetpack = require('fs-jetpack'); +const path = require('path'); +const sharp = require('sharp'); +const ffmpeg = require('fluent-ffmpeg'); +const previewUtil = require('./videoPreview/FragmentPreview'); + +const log = require('./Log'); + +class ThumbUtil { + static imageExtensions = ['.jpg', '.jpeg', '.gif', '.png', '.webp']; + static videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov']; + + static thumbPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs'); + static squareThumbPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs', 'square'); + static videoPreviewPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs', 'preview'); + + static generateThumbnails(filename) { + const ext = path.extname(filename).toLowerCase(); + const output = `${filename.slice(0, -ext.length)}.webp`; + const previewOutput = `${filename.slice(0, -ext.length)}.webm`; + + // eslint-disable-next-line max-len + if (ThumbUtil.imageExtensions.includes(ext)) return ThumbUtil.generateThumbnailForImage(filename, output); + // eslint-disable-next-line max-len + if (ThumbUtil.videoExtensions.includes(ext)) return ThumbUtil.generateThumbnailForVideo(filename, previewOutput); + return null; + } + + static async generateThumbnailForImage(filename, output) { + const filePath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, filename); + + const file = await jetpack.readAsync(filePath, 'buffer'); + await sharp(file) + .resize(64, 64) + .toFormat('webp') + .toFile(path.join(ThumbUtil.squareThumbPath, output)); + await sharp(file) + .resize(225, null) + .toFormat('webp') + .toFile(path.join(ThumbUtil.thumbPath, output)); + } + + static async generateThumbnailForVideo(filename, output) { + const filePath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, filename); + + ffmpeg(filePath) + .thumbnail({ + timestamps: [0], + filename: '%b.webp', + folder: ThumbUtil.squareThumbPath, + size: '64x64' + }) + .on('error', error => log.error(error.message)); + + ffmpeg(filePath) + .thumbnail({ + timestamps: [0], + filename: '%b.webp', + folder: ThumbUtil.thumbPath, + size: '150x?' + }) + .on('error', error => log.error(error.message)); + + try { + await previewUtil({ + input: filePath, + width: 150, + output: path.join(ThumbUtil.videoPreviewPath, output) + }); + } catch (e) { + log.error(e); + } + } + + static getFileThumbnail(filename) { + if (!filename) return null; + const ext = path.extname(filename).toLowerCase(); + + const isImage = ThumbUtil.imageExtensions.includes(ext); + const isVideo = ThumbUtil.videoExtensions.includes(ext); + + if (isImage) return { thumb: `${filename.slice(0, -ext.length)}.webp` }; + if (isVideo) { + return { + thumb: `${filename.slice(0, -ext.length)}.webp`, + preview: `${filename.slice(0, -ext.length)}.webm` + }; + } + + return null; + } + + static async removeThumbs({ thumb, preview }) { + if (thumb) { + await jetpack.removeAsync(path.join(ThumbUtil.thumbPath, thumb)); + await jetpack.removeAsync(path.join(ThumbUtil.squareThumbPath, thumb)); + } + if (preview) { + await jetpack.removeAsync(path.join(ThumbUtil.videoPreviewPath, preview)); + } + } +} + +module.exports = ThumbUtil; diff --git a/src/api/utils/Util.js b/src/api/utils/Util.js new file mode 100644 index 0000000..e52fac2 --- /dev/null +++ b/src/api/utils/Util.js @@ -0,0 +1,296 @@ +/* eslint-disable no-await-in-loop */ +const jetpack = require('fs-jetpack'); +const randomstring = require('randomstring'); +const path = require('path'); +const JWT = require('jsonwebtoken'); +const db = require('knex')({ + client: process.env.DB_CLIENT, + connection: { + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + filename: path.join(__dirname, '../../../database/database.sqlite') + }, + useNullAsDefault: process.env.DB_CLIENT === 'sqlite' +}); +const moment = require('moment'); +const crypto = require('crypto'); +const Zip = require('adm-zip'); +const uuidv4 = require('uuid/v4'); + +const log = require('./Log'); +const ThumbUtil = require('./ThumbUtil'); + +const blockedExtensions = process.env.BLOCKED_EXTENSIONS.split(','); + +class Util { + static uploadPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER); + + static uuid() { + return uuidv4(); + } + + static isExtensionBlocked(extension) { + return blockedExtensions.includes(extension); + } + + static constructFilePublicLink(file) { + /* + TODO: This wont work without a reverse proxy serving both + the site and the API under the same domain. Pls fix. + */ + file.url = `${process.env.DOMAIN}/${file.name}`; + const { thumb, preview } = ThumbUtil.getFileThumbnail(file.name) || {}; + if (thumb) { + file.thumb = `${process.env.DOMAIN}/thumbs/${thumb}`; + file.thumbSquare = `${process.env.DOMAIN}/thumbs/square/${thumb}`; + file.preview = preview && `${process.env.DOMAIN}/thumbs/preview/${preview}`; + } + return file; + } + + static getUniqueFilename(name) { + const retry = (i = 0) => { + const filename = randomstring.generate({ + length: parseInt(process.env.GENERATED_FILENAME_LENGTH, 10), + capitalization: 'lowercase' + }) + path.extname(name).toLowerCase(); + + // TODO: Change this to look for the file in the db instead of in the filesystem + const exists = jetpack.exists(path.join(Util.uploadPath, filename)); + if (!exists) return filename; + if (i < 5) return retry(i + 1); + log.error('Couldnt allocate identifier for file'); + return null; + }; + return retry(); + } + + static getUniqueAlbumIdentifier() { + const retry = async (i = 0) => { + const identifier = randomstring.generate({ + length: parseInt(process.env.GENERATED_ALBUM_LENGTH, 10), + capitalization: 'lowercase' + }); + const exists = await db + .table('links') + .where({ identifier }) + .first(); + if (!exists) return identifier; + /* + It's funny but if you do i++ the asignment never gets done resulting in an infinite loop + */ + if (i < 5) return retry(i + 1); + log.error('Couldnt allocate identifier for album'); + return null; + }; + return retry(); + } + + static async getFileHash(filename) { + const file = await jetpack.readAsync(path.join(Util.uploadPath, filename), 'buffer'); + if (!file) { + log.error(`There was an error reading the file < ${filename} > for hashing`); + return null; + } + + const hash = crypto.createHash('md5'); + hash.update(file, 'utf8'); + return hash.digest('hex'); + } + + static generateFileHash(data) { + const hash = crypto + .createHash('md5') + .update(data) + .digest('hex'); + return hash; + } + + static async checkIfFileExists(db, user, hash) { + const exists = await db.table('files') + .where(function() { // eslint-disable-line func-names + if (user) this.where('userId', user.id); + else this.whereNull('userId'); + }) + .where({ hash }) + .first(); + return exists; + } + + static getFilenameFromPath(fullPath) { + return fullPath.replace(/^.*[\\\/]/, ''); // eslint-disable-line no-useless-escape + } + + static async deleteFile(filename, deleteFromDB = false) { + const thumbName = ThumbUtil.getFileThumbnail(filename); + try { + await jetpack.removeAsync(path.join(Util.uploadPath, filename)); + await ThumbUtil.removeThumbs(thumbName); + + if (deleteFromDB) { + await db + .table('files') + .where('name', filename) + .delete(); + } + } catch (error) { + log.error(`There was an error removing the file < ${filename} >`); + log.error(error); + } + } + + static async deleteAllFilesFromAlbum(id) { + try { + const fileAlbums = await db.table('albumsFiles').where({ albumId: id }); + for (const fileAlbum of fileAlbums) { + const file = await db + .table('files') + .where({ id: fileAlbum.fileId }) + .first(); + + if (!file) continue; + + await this.deleteFile(file.name, true); + } + } catch (error) { + log.error(error); + } + } + + static async deleteAllFilesFromUser(id) { + try { + const files = await db.table('files').where({ userId: id }); + for (const file of files) { + await this.deleteFile(file.name, true); + } + } catch (error) { + log.error(error); + } + } + + static async deleteAllFilesFromTag(id) { + try { + const fileTags = await db.table('fileTags').where({ tagId: id }); + for (const fileTag of fileTags) { + const file = await db + .table('files') + .where({ id: fileTag.fileId }) + .first(); + if (!file) continue; + await this.deleteFile(file.name, true); + } + } catch (error) { + log.error(error); + } + } + + static async isAuthorized(req) { + if (req.headers.token) { + const user = await db.table('users').where({ apiKey: req.headers.token }).first(); + if (!user || !user.enabled) return false; + return user; + } + + if (!req.headers.authorization) return false; + const token = req.headers.authorization.split(' ')[1]; + if (!token) return false; + + return JWT.verify(token, process.env.SECRET, async (error, decoded) => { + if (error) { + log.error(error); + return false; + } + const id = decoded ? decoded.sub : ''; + const iat = decoded ? decoded.iat : ''; + + const user = await db + .table('users') + .where({ id }) + .first(); + if (!user || !user.enabled) return false; + if (iat && iat < moment(user.passwordEditedAt).format('x')) return false; + + return user; + }); + } + + static createZip(files, album) { + try { + const zip = new Zip(); + for (const file of files) { + zip.addLocalFile(path.join(Util.uploadPath, file)); + } + zip.writeZip( + path.join( + __dirname, + '../../../', + process.env.UPLOAD_FOLDER, + 'zips', + `${album.userId}-${album.id}.zip` + ) + ); + } catch (error) { + log.error(error); + } + } + + static generateThumbnails = ThumbUtil.generateThumbnails; + static async saveFileToDatabase(req, res, user, db, file, originalFile) { + /* + Save the upload information to the database + */ + const now = moment.utc().toDate(); + let insertedId = null; + try { + /* + This is so fucking dumb + */ + if (process.env.DB_CLIENT === 'sqlite3') { + insertedId = await db.table('files').insert({ + userId: user ? user.id : null, + name: file.name, + original: originalFile.originalname, + type: originalFile.mimetype || '', + size: file.size, + hash: file.hash, + ip: req.ip, + createdAt: now, + editedAt: now + }); + } else { + insertedId = await db.table('files').insert({ + userId: user ? user.id : null, + name: file.name, + original: originalFile.originalname, + type: originalFile.mimetype || '', + size: file.size, + hash: file.hash, + ip: req.ip, + createdAt: now, + editedAt: now + }, 'id'); + } + return insertedId; + } catch (error) { + console.error('There was an error saving the file to the database'); + console.error(error); + return null; + } + } + + static async saveFileToAlbum(db, albumId, insertedId) { + if (!albumId) return; + + const now = moment.utc().toDate(); + try { + await db.table('albumsFiles').insert({ albumId, fileId: insertedId[0] }); + await db.table('albums').where('id', albumId).update('editedAt', now); + } catch (error) { + console.error(error); + } + } +} + +module.exports = Util; diff --git a/src/api/utils/generateThumbs.js b/src/api/utils/generateThumbs.js new file mode 100644 index 0000000..d2cd91b --- /dev/null +++ b/src/api/utils/generateThumbs.js @@ -0,0 +1,17 @@ +require('dotenv').config(); + +const fs = require('fs'); +const path = require('path'); + +const ThumbUtil = require('./ThumbUtil'); + +const start = async () => { + const files = fs.readdirSync(path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER)); + for (const fileName of files) { + console.log(`Generating thumb for '${fileName}`); + // eslint-disable-next-line no-await-in-loop + await ThumbUtil.generateThumbnails(fileName); + } +}; + +start(); diff --git a/src/api/utils/videoPreview/FragmentPreview.js b/src/api/utils/videoPreview/FragmentPreview.js new file mode 100644 index 0000000..1d1ee02 --- /dev/null +++ b/src/api/utils/videoPreview/FragmentPreview.js @@ -0,0 +1,88 @@ +/* eslint-disable no-bitwise */ +const ffmpeg = require('fluent-ffmpeg'); +const probe = require('ffmpeg-probe'); + +const noop = () => {}; + +const getRandomInt = (min, max) => { + const minInt = Math.ceil(min); + const maxInt = Math.floor(max); + + // eslint-disable-next-line no-mixed-operators + return Math.floor(Math.random() * (maxInt - minInt + 1) + minInt); +}; + +const getStartTime = (vDuration, fDuration, ignoreBeforePercent, ignoreAfterPercent) => { + // by subtracting the fragment duration we can be sure that the resulting + // start time + fragment duration will be less than the video duration + const safeVDuration = vDuration - fDuration; + + // if the fragment duration is longer than the video duration + if (safeVDuration <= 0) { + return 0; + } + + return getRandomInt(ignoreBeforePercent * safeVDuration, ignoreAfterPercent * safeVDuration); +}; + +module.exports = async opts => { + const { + log = noop, + + // general output options + quality = 2, + width, + height, + input, + output, + + fragmentDurationSecond = 3, + ignoreBeforePercent = 0.25, + ignoreAfterPercent = 0.75 + } = opts; + + const info = await probe(input); + + let { duration } = info.format; + duration = parseInt(duration, 10); + + const startTime = getStartTime(duration, fragmentDurationSecond, ignoreBeforePercent, ignoreAfterPercent); + + const result = { startTime, duration }; + + await new Promise((resolve, reject) => { + let scale = null; + + if (width && height) { + result.width = width | 0; + result.height = height | 0; + scale = `scale=${width}:${height}`; + } else if (width) { + result.width = width | 0; + result.height = ((info.height * width) / info.width) | 0; + scale = `scale=${width}:-1`; + } else if (height) { + result.height = height | 0; + result.width = ((info.width * height) / info.height) | 0; + scale = `scale=-1:${height}`; + } else { + result.width = info.width; + result.height = info.height; + } + + return ffmpeg() + .input(input) + .inputOptions([`-ss ${startTime}`]) + .outputOptions(['-vsync', 'vfr']) + .outputOptions(['-q:v', quality, '-vf', scale]) + .outputOptions([`-t ${fragmentDurationSecond}`]) + .noAudio() + .output(output) + .on('start', cmd => log && log({ cmd })) + .on('end', resolve) + .on('error', reject) + .run(); + }); + + return result; +}; diff --git a/src/api/utils/videoPreview/FrameIntervalPreview.js b/src/api/utils/videoPreview/FrameIntervalPreview.js new file mode 100644 index 0000000..96c6e3a --- /dev/null +++ b/src/api/utils/videoPreview/FrameIntervalPreview.js @@ -0,0 +1,73 @@ +/* eslint-disable no-bitwise */ +const ffmpeg = require('fluent-ffmpeg'); +const probe = require('ffmpeg-probe'); + +const noop = () => {}; + +module.exports = async opts => { + const { + log = noop, + + // general output options + quality = 2, + width, + height, + input, + output, + + numFrames, + numFramesPercent = 0.05 + } = opts; + + const info = await probe(input); + // const numFramesTotal = parseInt(info.streams[0].nb_frames, 10); + const { avg_frame_rate: avgFrameRate, duration } = info.streams[0]; + const [frames, time] = avgFrameRate.split('/').map(e => parseInt(e, 10)); + + const numFramesTotal = (frames / time) * duration; + + let numFramesToCapture = numFrames || numFramesPercent * numFramesTotal; + numFramesToCapture = Math.max(1, Math.min(numFramesTotal, numFramesToCapture)) | 0; + const nthFrame = (numFramesTotal / numFramesToCapture) | 0; + + const result = { + output, + numFrames: numFramesToCapture + }; + + await new Promise((resolve, reject) => { + let scale = null; + + if (width && height) { + result.width = width | 0; + result.height = height | 0; + scale = `scale=${width}:${height}`; + } else if (width) { + result.width = width | 0; + result.height = ((info.height * width) / info.width) | 0; + scale = `scale=${width}:-1`; + } else if (height) { + result.height = height | 0; + result.width = ((info.width * height) / info.height) | 0; + scale = `scale=-1:${height}`; + } else { + result.width = info.width; + result.height = info.height; + } + + const filter = [`select=not(mod(n\\,${nthFrame}))`, scale].filter(Boolean).join(','); + + ffmpeg(input) + .outputOptions(['-vsync', 'vfr']) + .outputOptions(['-q:v', quality, '-vf', filter]) + .noAudio() + .outputFormat('webm') + .output(output) + .on('start', cmd => log && log({ cmd })) + .on('end', () => resolve()) + .on('error', err => reject(err)) + .run(); + }); + + return result; +}; |