aboutsummaryrefslogtreecommitdiff
path: root/src/api/utils
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/utils')
-rw-r--r--src/api/utils/Log.js36
-rw-r--r--src/api/utils/QueryHelper.js200
-rw-r--r--src/api/utils/ThumbUtil.js104
-rw-r--r--src/api/utils/Util.js296
-rw-r--r--src/api/utils/generateThumbs.js17
-rw-r--r--src/api/utils/videoPreview/FragmentPreview.js88
-rw-r--r--src/api/utils/videoPreview/FrameIntervalPreview.js73
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;
+};