From 6d779e78110ad7b80b6f9cd289e0c8f6109e2927 Mon Sep 17 00:00:00 2001 From: waveringana Date: Sun, 29 Oct 2023 21:04:58 -0400 Subject: [PATCH] move ffmpeg code to its own file, rename types folder to lib --- app/app.ts | 2 +- app/{types => lib}/db.ts | 0 .../declarations/ffprobepath.d.ts | 0 app/lib/ffmpeg.ts | 188 ++++++++++++++ app/{types => lib}/lib.ts | 0 app/lib/middleware.ts | 107 ++++++++ app/{types => lib}/multer.ts | 0 app/routes/adduser.ts | 2 +- app/routes/auth.ts | 4 +- app/routes/index.ts | 8 +- app/types/middleware.ts | 243 ------------------ tests/.gitignore | 6 + tests/ffmpeg.ts | 3 +- 13 files changed, 310 insertions(+), 253 deletions(-) rename app/{types => lib}/db.ts (100%) rename app/{types => lib}/declarations/ffprobepath.d.ts (100%) create mode 100644 app/lib/ffmpeg.ts rename app/{types => lib}/lib.ts (100%) create mode 100644 app/lib/middleware.ts rename app/{types => lib}/multer.ts (100%) delete mode 100644 app/types/middleware.ts create mode 100644 tests/.gitignore diff --git a/app/app.ts b/app/app.ts index 4672266..3d026e9 100644 --- a/app/app.ts +++ b/app/app.ts @@ -17,7 +17,7 @@ import authRouter from "./routes/auth"; import indexRouter from "./routes/index"; import adduserRouter from "./routes/adduser"; -import {db, expire, createDatabase, updateDatabase, MediaRow} from "./types/db"; +import {db, expire, createDatabase, updateDatabase, MediaRow} from "./lib/db"; const app = express(); const server = http.createServer(app); diff --git a/app/types/db.ts b/app/lib/db.ts similarity index 100% rename from app/types/db.ts rename to app/lib/db.ts diff --git a/app/types/declarations/ffprobepath.d.ts b/app/lib/declarations/ffprobepath.d.ts similarity index 100% rename from app/types/declarations/ffprobepath.d.ts rename to app/lib/declarations/ffprobepath.d.ts diff --git a/app/lib/ffmpeg.ts b/app/lib/ffmpeg.ts new file mode 100644 index 0000000..a614908 --- /dev/null +++ b/app/lib/ffmpeg.ts @@ -0,0 +1,188 @@ +import { extension, videoExtensions, imageExtensions } from "./lib"; + +import ffmpeg from 'fluent-ffmpeg'; +import ffmpegInstaller from '@ffmpeg-installer/ffmpeg'; +import ffprobeInstaller from '@ffprobe-installer/ffprobe'; +import which from 'which'; + +/** + * Enum to represent different types of video encoding methods. + * + * @enum {string} + * @property {string} CPU - Uses the libx264 codec for CPU-based encoding + * @property {string} NVIDIA - Uses the h264_nvenc codec for NVIDIA GPU-based encoding + * @property {string} AMD - Uses the h264_amf codec for AMD GPU-based encoding + * @property {string} INTEL - Uses the h264_qsv codec for Intel GPU-based encoding + * @property {string} APPLE - Uses the h264_videotoolbox codec for Apple GPU-based encoding + */ +export enum EncodingType { + CPU = 'libx264', + NVIDIA = 'h264_nvenc', + AMD = 'h264_amf', + INTEL = 'h264_qsv', + APPLE = 'h264_videotoolbox' +} + +/** + * The current encoding type being used for video encoding. + * @type {EncodingType} + */ +export let currentEncoding: EncodingType = EncodingType.CPU; + +/** + * Sets the current encoding type. + * + * @param {EncodingType} type - The encoding type to set. + */ +export const setEncodingType = (type: EncodingType) => { + currentEncoding = type; +}; + +/** + * Returns the path to an executable by checking environment variables, the system path, or a default installer. + * + * @param {string} envVar - The environment variable to check for the executable's path. + * @param {string} executable - The name of the executable to search for in the system path. + * @param {Object} installer - An object containing the default installer path. + * @param {string} installer.path - The default path to use if the executable is not found in the environment or system path. + * + * @returns {string} - The path to the executable. + * @throws Will throw an error if the executable is not found and the installer path is not available. + */ +const getExecutablePath = (envVar: string, executable: string, installer: { path: string }) => { + if (process.env[envVar]) { + return process.env[envVar]; + } + + try { + return which.sync(executable); + } catch (error) { + return installer.path; + } +}; + +const ffmpegPath = getExecutablePath('EB_FFMPEG_PATH', 'ffmpeg', ffmpegInstaller); +const ffprobePath = getExecutablePath('EB_FFPROBE_PATH', 'ffprobe', ffprobeInstaller); + +console.log(`Using ffmpeg from path: ${ffmpegPath}`); +console.log(`Using ffprobe from path: ${ffprobePath}`); + +ffmpeg.setFfmpegPath(ffmpegPath!); +ffmpeg.setFfprobePath(ffprobePath!); + +const checkEnvForEncoder = () => { + const envEncoder = process.env.EB_ENCODER?.toUpperCase(); + + if (envEncoder && Object.keys(EncodingType).includes(envEncoder)) { + setEncodingType(EncodingType[envEncoder as keyof typeof EncodingType] as EncodingType); + console.log(`Setting encoding type to ${envEncoder} based on environment variable.`); + } else if (envEncoder) { + console.warn(`Invalid encoder value "${envEncoder}" in environment variable, defaulting to CPU.`); + } +}; + +checkEnvForEncoder(); + +/** + * Downscale a video using ffmpeg with various encoding options. + * + * @param {string} path - The input video file path. + * @param {string} filename - The name of the file. + * @param {string} extension - The file extension of the file + * @returns {Promise} - A promise that resolves when the downscaling is complete, and rejects on error. + * + * @example + * ffmpegDownscale('input.mp4').then(() => { + * console.log('Downscaling complete.'); + * }).catch((error) => { + * console.log(`Error: ${error}`); + * }); + */ +export const ffmpegDownscale = (path: string, filename: string, extension: string) => { + const startTime = Date.now(); + const outputOptions = [ + '-vf', 'scale=-2:720', + '-c:v', currentEncoding, + '-c:a', 'copy', + ]; + + // Adjust output options based on encoder for maximum quality + switch (currentEncoding) { + case EncodingType.CPU: + outputOptions.push('-crf', '0'); + break; + case EncodingType.NVIDIA: + outputOptions.push('-rc', 'cqp', '-qp', '0'); + break; + case EncodingType.AMD: + outputOptions.push('-qp_i', '0', '-qp_p', '0', '-qp_b', '0'); + break; + case EncodingType.INTEL: + outputOptions.push('-global_quality', '1'); // Intel QSV specific setting for high quality + break; + case EncodingType.APPLE: + outputOptions.push('-global_quality', '1'); + break; + } + + return new Promise((resolve, reject) => { + ffmpeg() + .input(path) + .outputOptions(outputOptions) + .output(`uploads/720p-${filename}${extension}`) + .on('end', () => { + console.log(`720p copy complete using ${currentEncoding}, took ${Date.now() - startTime}ms to complete`); + resolve(); + }) + .on('error', (e) => reject(new Error(e))) + .run(); + }); +} + +/** Converts video to gif and vice versa using ffmpeg */ +/**export const convert: Middleware = (req, res, next) => { + const files = req.files as Express.Multer.File[]; + + for (const file in files) { + const nameAndExtension = extension(files[file].originalname); + + if (videoExtensions.includes(nameAndExtension[1])) { + console.log("Converting " + nameAndExtension[0] + nameAndExtension[1] + " to gif"); + console.log(`Using ${currentEncoding} as encoder`); + const startTime = Date.now(); + ffmpeg() + .input(`uploads/${nameAndExtension[0]}${nameAndExtension[1]}`) + .inputFormat(nameAndExtension[1].substring(1)) + .outputOptions(`-c:v ${currentEncoding}`) + .outputFormat("gif") + .output(`uploads/${nameAndExtension[0]}.gif`) + .on("end", function() { + console.log(`Conversion complete, took ${Date.now() - startTime} to complete`); + console.log(`Uploaded to uploads/${nameAndExtension[0]}.gif`); + }) + .on("error", (e) => console.log(e)) + .run(); + } else if (nameAndExtension[1] == ".gif") { + console.log(`Converting ${nameAndExtension[0]}${nameAndExtension[1]} to mp4`); + console.log(`Using ${currentEncoding} as encoder`); + + const startTime = Date.now(); + ffmpeg(`uploads/${nameAndExtension[0]}${nameAndExtension[1]}`) + .inputFormat("gif") + .outputFormat("mp4") + .outputOptions([ + "-pix_fmt yuv420p", + `-c:v ${currentEncoding}`, + "-movflags +faststart" + ]) + .noAudio() + .output(`uploads/${nameAndExtension[0]}.mp4`) + .on("end", function() { + console.log(`Conversion complete, took ${Date.now() - startTime} to complete`); + console.log(`Uploaded to uploads/${nameAndExtension[0]}.mp4`); + next(); + }) + .run(); + } + } +};**/ \ No newline at end of file diff --git a/app/types/lib.ts b/app/lib/lib.ts similarity index 100% rename from app/types/lib.ts rename to app/lib/lib.ts diff --git a/app/lib/middleware.ts b/app/lib/middleware.ts new file mode 100644 index 0000000..60f29d3 --- /dev/null +++ b/app/lib/middleware.ts @@ -0,0 +1,107 @@ +import type {RequestHandler as Middleware, NextFunction} from "express"; + +import fs from "fs"; +import process from "process"; + +import {extension, videoExtensions, imageExtensions} from "./lib"; +import {db, MediaParams, insertToDB} from "./db"; +import {ffmpegDownscale} from "./ffmpeg"; + +export const checkAuth: Middleware = (req, res, next) => { + if (!req.user) { + return res.status(401); + } + next(); +}; + +/**Checks shareX auth key */ +export const checkSharexAuth: Middleware = (req, res, next) => { + const auth = process.env.EBAPI_KEY || process.env.EBPASS || "pleaseSetAPI_KEY"; + let key = null; + + if (req.headers["key"]) { + key = req.headers["key"]; + } else { + return res.status(400).send("{success: false, message: \"No key provided\", fix: \"Provide a key\"}"); + } + + if (auth != key) { + return res.status(401).send("{success: false, message: '\"'Invalid key\", fix: \"Provide a valid key\"}"); + } + + const shortKey = key.substr(0, 3) + "..."; + console.log(`Authenicated user with key: ${shortKey}`); + + next(); +}; + +/**Creates oembed json file for embed metadata */ +export const createEmbedData: Middleware = (req, res, next) => { + const files = req.files as Express.Multer.File[]; + for (const file in files) { + const nameAndExtension = extension(files[file].originalname); + const oembed = { + type: "video", + version: "1.0", + provider_name: "embedder", + provider_url: "https://github.com/WaveringAna/embedder", + cache_age: 86400, + html: ``, + width: 640, + height: 360 + }; + + fs.writeFile(`uploads/oembed-${nameAndExtension[0]}${nameAndExtension[1]}.json`, JSON.stringify(oembed), function (err) { + if (err) return next(err); + console.log(`oembed file created ${nameAndExtension[0]}${nameAndExtension[1]}.json`); + }); + } + next(); +}; + +/**Creates a 720p copy of video for smaller file */ +export const convertTo720p: Middleware = (req, res, next) => { + const files = req.files as Express.Multer.File[]; + console.log("convert to 720p running"); + for (const file in files) { + const nameAndExtension = extension(files[file].originalname); + + //Skip if not a video + if (!videoExtensions.includes(nameAndExtension[1]) && nameAndExtension[1] !== ".gif") { + console.log(`${files[file].originalname} is not a video file`); + console.log(nameAndExtension[1]); + continue; + } + + console.log(`Creating 720p for ${files[file].originalname}`); + + ffmpegDownscale(`uploads/${nameAndExtension[0]}${nameAndExtension[1]}`, nameAndExtension[0], nameAndExtension[1]).then(() => { + //Nothing for now, can fire event flag that it is done to front end when react conversion is done + }).catch((error) => { + console.log(`Error: ${error}`); + }); + } + + next(); +}; + +/**Middleware for handling uploaded files. Inserts it into the database */ +export const handleUpload: Middleware = (req, res, next) => { + if (!req.file && !req.files) { + console.log("No files were uploaded"); + return res.status(400).send("No files were uploaded."); + } + + const files = (req.files) ? req.files as Express.Multer.File[] : req.file; //Check if a single file was uploaded or multiple + const username = (req.user) ? req.user.username : "sharex"; //if no username was provided, we can presume that it is sharex + const expireDate: Date = (req.body.expire) ? new Date(Date.now() + (req.body.expire * 24 * 60 * 60 * 1000)) : null; + + if (files instanceof Array) { + for (const file in files) { + insertToDB(files[file].filename, expireDate, username); + } + } else + insertToDB(files.filename, expireDate, username); + + next(); +}; diff --git a/app/types/multer.ts b/app/lib/multer.ts similarity index 100% rename from app/types/multer.ts rename to app/lib/multer.ts diff --git a/app/routes/adduser.ts b/app/routes/adduser.ts index e9a32dd..926b0cb 100644 --- a/app/routes/adduser.ts +++ b/app/routes/adduser.ts @@ -1,7 +1,7 @@ import type {RequestHandler as Middleware, Router, Request, Response, NextFunction} from "express"; import express from "express"; -import {createUser} from "../types/db"; +import {createUser} from "../lib/db"; const router: Router = express.Router(); /**Middleware to check if a user is actually signed in */ diff --git a/app/routes/auth.ts b/app/routes/auth.ts index 9bee3cf..1103a8b 100644 --- a/app/routes/auth.ts +++ b/app/routes/auth.ts @@ -3,8 +3,8 @@ import express from "express"; import passport from "passport"; import {Strategy as LocalStrategy} from "passport-local"; -import {User} from "../types/lib"; -import {db, UserRow} from "../types/db"; +import {User} from "../lib/lib"; +import {db, UserRow} from "../lib/db"; const router = express.Router(); diff --git a/app/routes/index.ts b/app/routes/index.ts index f42eac7..fc19091 100644 --- a/app/routes/index.ts +++ b/app/routes/index.ts @@ -11,10 +11,10 @@ ffmpeg.setFfprobePath(ffprobepath.path); import fs from "fs"; -import {extension, videoExtensions} from "../types/lib"; -import {db, MediaRow, getPath, deleteId} from "../types/db"; -import {fileStorage} from "../types/multer"; -import {checkAuth, checkSharexAuth, convertTo720p, createEmbedData, handleUpload} from "../types/middleware"; +import {extension, videoExtensions} from "../lib/lib"; +import {db, MediaRow, getPath, deleteId} from "../lib/db"; +import {fileStorage} from "../lib/multer"; +import {checkAuth, checkSharexAuth, convertTo720p, createEmbedData, handleUpload} from "../lib/middleware"; const upload = multer({ storage: fileStorage /**, fileFilter: fileFilter**/ }); //maybe make this a env variable? /**Middleware to grab media from media database */ diff --git a/app/types/middleware.ts b/app/types/middleware.ts deleted file mode 100644 index ac41be9..0000000 --- a/app/types/middleware.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type {RequestHandler as Middleware, NextFunction} from "express"; - -import ffmpeg from 'fluent-ffmpeg'; -import ffmpegInstaller from '@ffmpeg-installer/ffmpeg'; -import ffprobeInstaller from '@ffprobe-installer/ffprobe'; -import which from 'which'; - -//weird error that occurs where if I use the alias 'process', node cannot access it -import Process from 'node:process'; - -const getExecutablePath = (envVar: string, executable: string, installer: { path: string }) => { - if (Process.env[envVar]) { - return Process.env[envVar]; - } - - try { - return which.sync(executable); - } catch (error) { - return installer.path; - } -}; - -const ffmpegPath = getExecutablePath('EB_FFMPEG_PATH', 'ffmpeg', ffmpegInstaller); -const ffprobePath = getExecutablePath('EB_FFPROBE_PATH', 'ffprobe', ffprobeInstaller); - -console.log(`Using ffmpeg from path: ${ffmpegPath}`); -console.log(`Using ffprobe from path: ${ffprobePath}`); - -ffmpeg.setFfmpegPath(ffmpegPath!); -ffmpeg.setFfprobePath(ffprobePath!); - -import fs from "fs"; -import process from "process"; - -import {extension, videoExtensions, imageExtensions} from "./lib"; -import {db, MediaParams, insertToDB} from "./db"; - -enum EncodingType { - CPU = 'libx264', - NVIDIA = 'h264_nvenc', - AMD = 'h264_amf', - INTEL = 'h264_qsv', - APPLE = 'h264_videotoolbox' -} - -let currentEncoding: EncodingType = EncodingType.CPU; - -export const setEncodingType = (type: EncodingType) => { - currentEncoding = type; -}; - -export const checkEnvForEncoder = () => { - const envEncoder = process.env.EB_ENCODER?.toUpperCase(); - - if (envEncoder && Object.keys(EncodingType).includes(envEncoder)) { - setEncodingType(EncodingType[envEncoder as keyof typeof EncodingType] as EncodingType); - console.log(`Setting encoding type to ${envEncoder} based on environment variable.`); - } else if (envEncoder) { - //I finally understand DHH - console.warn(`Invalid encoder value "${envEncoder}" in environment variable, defaulting to ${Object.keys(EncodingType).find(key => EncodingType[key as keyof typeof EncodingType] === currentEncoding)}.`); - } -}; - -checkEnvForEncoder(); - -export const checkAuth: Middleware = (req, res, next) => { - if (!req.user) { - return res.status(401); - } - next(); -}; - -/**Checks shareX auth key */ -export const checkSharexAuth: Middleware = (req, res, next) => { - const auth = process.env.EBAPI_KEY || process.env.EBPASS || "pleaseSetAPI_KEY"; - let key = null; - - if (req.headers["key"]) { - key = req.headers["key"]; - } else { - return res.status(400).send("{success: false, message: \"No key provided\", fix: \"Provide a key\"}"); - } - - if (auth != key) { - return res.status(401).send("{success: false, message: '\"'Invalid key\", fix: \"Provide a valid key\"}"); - } - - const shortKey = key.substr(0, 3) + "..."; - console.log(`Authenicated user with key: ${shortKey}`); - - next(); -}; - -/**Creates oembed json file for embed metadata */ -export const createEmbedData: Middleware = (req, res, next) => { - const files = req.files as Express.Multer.File[]; - for (const file in files) { - const nameAndExtension = extension(files[file].originalname); - const oembed = { - type: "video", - version: "1.0", - provider_name: "embedder", - provider_url: "https://github.com/WaveringAna/embedder", - cache_age: 86400, - html: ``, - width: 640, - height: 360 - }; - - fs.writeFile(`uploads/oembed-${nameAndExtension[0]}${nameAndExtension[1]}.json`, JSON.stringify(oembed), function (err) { - if (err) return next(err); - console.log(`oembed file created ${nameAndExtension[0]}${nameAndExtension[1]}.json`); - }); - } - next(); -}; - -/** Converts video to gif and vice versa using ffmpeg */ -export const convert: Middleware = (req, res, next) => { - const files = req.files as Express.Multer.File[]; - - for (const file in files) { - const nameAndExtension = extension(files[file].originalname); - - if (videoExtensions.includes(nameAndExtension[1])) { - console.log("Converting " + nameAndExtension[0] + nameAndExtension[1] + " to gif"); - console.log(`Using ${currentEncoding} as encoder`); - const startTime = Date.now(); - ffmpeg() - .input(`uploads/${nameAndExtension[0]}${nameAndExtension[1]}`) - .inputFormat(nameAndExtension[1].substring(1)) - .outputOptions(`-c:v ${currentEncoding}`) - .outputFormat("gif") - .output(`uploads/${nameAndExtension[0]}.gif`) - .on("end", function() { - console.log(`Conversion complete, took ${Date.now() - startTime} to complete`); - console.log(`Uploaded to uploads/${nameAndExtension[0]}.gif`); - }) - .on("error", (e) => console.log(e)) - .run(); - } else if (nameAndExtension[1] == ".gif") { - console.log(`Converting ${nameAndExtension[0]}${nameAndExtension[1]} to mp4`); - console.log(`Using ${currentEncoding} as encoder`); - - const startTime = Date.now(); - ffmpeg(`uploads/${nameAndExtension[0]}${nameAndExtension[1]}`) - .inputFormat("gif") - .outputFormat("mp4") - .outputOptions([ - "-pix_fmt yuv420p", - `-c:v ${currentEncoding}`, - "-movflags +faststart" - ]) - .noAudio() - .output(`uploads/${nameAndExtension[0]}.mp4`) - .on("end", function() { - console.log(`Conversion complete, took ${Date.now() - startTime} to complete`); - console.log(`Uploaded to uploads/${nameAndExtension[0]}.mp4`); - next(); - }) - .run(); - } - } -}; - -/**Creates a 720p copy of video for smaller file */ -export const convertTo720p: Middleware = (req, res, next) => { - const files = req.files as Express.Multer.File[]; - console.log("convert to 720p running"); - for (const file in files) { - const nameAndExtension = extension(files[file].originalname); - - //Skip if not a video - if (!videoExtensions.includes(nameAndExtension[1]) && nameAndExtension[1] !== ".gif") { - console.log(`${files[file].originalname} is not a video file`); - console.log(nameAndExtension[1]); - continue; - } - - console.log(`Creating 720p for ${files[file].originalname}`); - - const startTime = Date.now(); - - const outputOptions = [ - '-vf', 'scale=-2:720', - '-c:v', currentEncoding, - ]; - - // Adjust output options based on encoder for maximum quality - switch(currentEncoding) { - case EncodingType.CPU: - outputOptions.push('-crf', '0'); - break; - case EncodingType.NVIDIA: - outputOptions.push('-rc', 'cqp', '-qp', '0'); - break; - case EncodingType.AMD: - outputOptions.push('-qp_i', '0', '-qp_p', '0', '-qp_b', '0'); - break; - case EncodingType.INTEL: - outputOptions.push('-global_quality', '1'); // Intel QSV specific setting for high quality - break; - case EncodingType.APPLE: - outputOptions.push('-global_quality', '1'); - break; - } - - - ffmpeg() - .input(`uploads/${nameAndExtension[0]}${nameAndExtension[1]}`) - .inputFormat('mp4') - .outputOptions(outputOptions) - .output(`uploads/720p-${nameAndExtension[0]}${nameAndExtension[1]}`) - .on('end', () => { - console.log(`720p copy complete using ${currentEncoding}, took ${Date.now() - startTime}ms to complete`); - }) - .on('error', (e) => console.log(e)) - .run(); - } - - next(); -}; - -/**Middleware for handling uploaded files. Inserts it into the database */ -export const handleUpload: Middleware = (req, res, next) => { - if (!req.file && !req.files) { - console.log("No files were uploaded"); - return res.status(400).send("No files were uploaded."); - } - - const files = (req.files) ? req.files as Express.Multer.File[] : req.file; //Check if a single file was uploaded or multiple - const username = (req.user) ? req.user.username : "sharex"; //if no username was provided, we can presume that it is sharex - const expireDate: Date = (req.body.expire) ? new Date(Date.now() + (req.body.expire * 24 * 60 * 60 * 1000)) : null; - - if (files instanceof Array) { - for (const file in files) { - insertToDB(files[file].filename, expireDate, username); - } - } else - insertToDB(files.filename, expireDate, username); - - next(); -}; diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..2523230 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,6 @@ +# ignore all mp4 except for test.mp4 +*.mp4 +!test.mp4 + +# ignore javascript files from tsc +*.js \ No newline at end of file diff --git a/tests/ffmpeg.ts b/tests/ffmpeg.ts index d07a953..3de7374 100644 --- a/tests/ffmpeg.ts +++ b/tests/ffmpeg.ts @@ -40,8 +40,7 @@ export const generateTestVideo = async (encodingType: EncodingType): Promise