Skip to content

Commit

Permalink
Added initial video encoding (WIP).
Browse files Browse the repository at this point in the history
  • Loading branch information
harrymaynard committed Aug 25, 2024
1 parent b57b2a3 commit 85f43a4
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 28 deletions.
34 changes: 34 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"dependencies": {
"canvas": "^2.8.0",
"commander": "^8.0.0",
"fluent-ffmpeg": "^2.1.3",
"gifencoder": "^2.0.1"
},
"devDependencies": {
Expand Down
30 changes: 30 additions & 0 deletions src/factories/BaseMediaFactory.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
}
}
31 changes: 3 additions & 28 deletions src/factories/ImageFactory.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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')
}

Expand All @@ -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)
}
Expand All @@ -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
63 changes: 63 additions & 0 deletions src/factories/VideoFactory.ts
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 85f43a4

Please sign in to comment.