From 85f43a4af564dce8fe63322eb2ed3deefb09c75a Mon Sep 17 00:00:00 2001 From: Harry Maynard Date: Sun, 25 Aug 2024 15:18:34 -0700 Subject: [PATCH] Added initial video encoding (WIP). --- package-lock.json | 34 +++++++++++++++++ package.json | 1 + src/factories/BaseMediaFactory.ts | 30 +++++++++++++++ src/factories/ImageFactory.ts | 31 ++------------- src/factories/VideoFactory.ts | 63 +++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 28 deletions(-) create mode 100644 src/factories/VideoFactory.ts diff --git a/package-lock.json b/package-lock.json index 7380f69..c1dbbd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "canvas": "^2.8.0", "commander": "^8.0.0", + "fluent-ffmpeg": "^2.1.3", "gifencoder": "^2.0.1" }, "bin": { @@ -476,6 +477,11 @@ "node": ">=10" } }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -622,6 +628,18 @@ "@esbuild/win32-x64": "0.23.1" } }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -763,6 +781,11 @@ "node": ">=8" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -1144,6 +1167,17 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/package.json b/package.json index d66cd05..7cd7f26 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dependencies": { "canvas": "^2.8.0", "commander": "^8.0.0", + "fluent-ffmpeg": "^2.1.3", "gifencoder": "^2.0.1" }, "devDependencies": { diff --git a/src/factories/BaseMediaFactory.ts b/src/factories/BaseMediaFactory.ts index 0f08bb7..86e84a0 100644 --- a/src/factories/BaseMediaFactory.ts +++ b/src/factories/BaseMediaFactory.ts @@ -1,3 +1,11 @@ +import { + type Canvas, + type CanvasRenderingContext2D, + createCanvas +} from 'canvas' + +const SQUARE_SIZE: number = 10 + export default class BaseMediaFactory { /** * Randomly gets a hex color code. @@ -11,4 +19,26 @@ export default class BaseMediaFactory { return '#FFF' } } + + /** + * Creates a random canvas image frame. + * @param {Number} width + * @param {Number} height + * @returns HTML canvas. + */ + protected static getCanvasFrame( + width: number, + height: number + ): Canvas { + const canvas: Canvas = createCanvas(width, height) + const context: CanvasRenderingContext2D = canvas.getContext('2d') + + for (let x = 0; x < width; x += SQUARE_SIZE) { + for (let y = 0; y < height; y += SQUARE_SIZE) { + context.fillStyle = this.getRandomBW() + context.fillRect(x, y, SQUARE_SIZE, SQUARE_SIZE) + } + } + return canvas + } } diff --git a/src/factories/ImageFactory.ts b/src/factories/ImageFactory.ts index 58d3e51..30b3a07 100644 --- a/src/factories/ImageFactory.ts +++ b/src/factories/ImageFactory.ts @@ -1,13 +1,10 @@ import { type Canvas, - type CanvasRenderingContext2D, - createCanvas + type CanvasRenderingContext2D } from 'canvas' import GIFEncoder from 'gifencoder' import BaseMediaFactory from '@/factories/BaseMediaFactory' -const SQUARE_SIZE: number = 10 - class ImageFactory extends BaseMediaFactory { /** * Creates a random PNG image. @@ -19,7 +16,7 @@ class ImageFactory extends BaseMediaFactory { width: number, height: number ): Buffer { - const canvas: Canvas = this._getCanvasFrame(width, height) + const canvas: Canvas = this.getCanvasFrame(width, height) return canvas.toBuffer('image/png') } @@ -46,7 +43,7 @@ class ImageFactory extends BaseMediaFactory { // Add frames. for (let i = 0; i < duration * fps; i++) { - const canvas: Canvas = this._getCanvasFrame(width, height) + const canvas: Canvas = this.getCanvasFrame(width, height) const context: CanvasRenderingContext2D = canvas.getContext('2d') encoder.addFrame(context) } @@ -57,28 +54,6 @@ class ImageFactory extends BaseMediaFactory { // Return data Buffer. return encoder.out.getData() } - - /** - * Creates a random canvas image frame. - * @param {Number} width - * @param {Number} height - * @returns HTML canvas. - */ - private static _getCanvasFrame( - width: number, - height: number - ): Canvas { - const canvas: Canvas = createCanvas(width, height) - const context: CanvasRenderingContext2D = canvas.getContext('2d') - - for (let x = 0; x < width; x += SQUARE_SIZE) { - for (let y = 0; y < height; y += SQUARE_SIZE) { - context.fillStyle = this.getRandomBW() - context.fillRect(x, y, SQUARE_SIZE, SQUARE_SIZE) - } - } - return canvas - } } export default ImageFactory diff --git a/src/factories/VideoFactory.ts b/src/factories/VideoFactory.ts new file mode 100644 index 0000000..fab1528 --- /dev/null +++ b/src/factories/VideoFactory.ts @@ -0,0 +1,63 @@ +import { + type Canvas, + type CanvasRenderingContext2D +} from 'canvas' +import FfmpegCommand from 'fluent-ffmpeg' +import BaseMediaFactory from '@/factories/BaseMediaFactory' + +class VideoFactory extends BaseMediaFactory { + /** + * Creates a random MP4 video. + * @param {Number} width + * @param {Number} height + * @param {Number} fps + * @param {Number} duration + * @returns video Buffer. + */ + public static async createMP4( + width: number, + height: number, + fps: number, + duration: number + ): Buffer { + const ffmpeg = new FfmpegCommand() + await new Promise((resolve, reject) => { + ffmpeg() + + // Tell FFmpeg to stitch all images together in the provided directory + .input(framesFilepath) + .inputOptions([ + // Set input frame rate + `-framerate ${frameRate}`, + ]) + + // Add the soundtrack + .input(soundtrackFilePath) + .audioFilters([ + // Fade out the volume 2 seconds before the end + `afade=out:st=${duration - 2}:d=2`, + ]) + + .videoCodec('libx264') + .outputOptions([ + // YUV color space with 4:2:0 chroma subsampling for maximum compatibility with + // video players + '-pix_fmt yuv420p', + ]) + + // Set the output duration. It is required because FFmpeg would otherwise + // automatically set the duration to the longest input, and the soundtrack might + // be longer than the desired video length + .duration(duration) + // Set output frame rate + .fps(frameRate) + + // Resolve or reject (throw an error) the Promise once FFmpeg completes + .saveToFile(outputFilepath) + .on('end', () => resolve()) + .on('error', (error) => reject(new Error(error))) + }) + } +} + +export default VideoFactory