A Deep Dive into a Video Rendering Pipeline
banger.show is a video app maker that heavily relies on background data processing
Igor Samokhovets· 5/7/2024 · 8 min read
Hi everyone! My name is Igor Samokhovets and I'm a music producer who goes by the artist name Tequila Funk. In this blog post I will walk you through our video rendering pipeline built with Inngest, which powers banger.show.
banger.show is a video maker app for musicians, DJs, and labels, which I built together with Mark Beziaev. It allows music industry people to create stunning visual assets for their music.
Creating a video for your new song takes only a few minutes and you don't need to install or learn any complex software because banger.show works in your browser!
Making background processing snappy
At banger.show, we do a lot of background processing. Managing render states, generating visual assets in the background, and even handling the rendering process on remote distributed workers.
We chose Inngest because there's no better way to handle background jobs if you're using Next.js without a custom server. It's a primary "flow controller" for us, even though we have a simple queue solution based on Redis to handle tasks on our infra. It allows us to orchestrate, observe, and abstract away lower-level processes, for example:
- We don't have to delegate every post-render task to the video rendering machine, but have some flow where the main server can receive render results from the workers and do something with it.
- We can observe each step and its return data in the dashboard.
- We can handle emergency cases when all of our infra goes down and we need to spin up some backup workers on AWS.
Before we dive in, let's take a high-level look at our video rendering pipeline:
- First, the app receives an uploaded project from the user.
- Next, it converts the audio for the more efficient rendering.
- The project is then sent to the render machine, which controls the progress, updates statuses, and handles error retries and "stalled render" cases.
- Finally, when the render is finished, a number of tasks need to run, such as invalidating CDN cache, creating video thumbnails, or sending email to the user when the video is ready.
Let's now dive into some parts of the video rendering pipeline.
1. Updating the render status
The first step in the render pipeline is to update the render status and set user credits on hold. Here we are using the default Inngest concurrency.
export const renderVideo = inngest.createFunction({name: 'Render video',id: 'render-video',cancelOn: [{event: 'banger/video.create',match: 'data.videoId'}],},{ event: 'banger/video.create' },async ({ event, step, attempt, logger }) => {const updatedVideo = await step.run('update-user-balance', async () => {await dbConnect()const render = await VideoModel.findOneAndUpdate({ _id: videoId },{ $set: { renderProgress: 0, renderTime: 0, status: 'pending' } },{ new: true }).populate('user').lean()invariant(video, 'no render found')// Simplifiedawait UserModel.updateOne({ _id: video.user._id },{ $inc: { unitsRemaining: -video.videoDuration } })return video})})
2. Cropping the audio file
In banger.show, the user selects a fragment of the song to create a "videoization" for it. In the background job, we:
- Crop audio file based on the user selection.
- Convert the file to mp3 format for disk space efficiency and optimal compatibility.
Let's see it in code.
const croppedMp3Url = await step.run('trim-audio-and-convert-to-mp3',async () => {// create temporary fileconst tempFilePath = `${os.tmpdir()}/${videoId}.mp3`await execa(`ffmpeg`, ['-i',updatedVideo.audioFileURL, // ffmpeg will grab input from URL'-map','0:a','-map_metadata','-1','-ab','320k','-f','aac','-ss',String(updatedVideo.regionStartTime), // start time'-to',String(updatedVideo.regionEndTime), // end timetempFilePath])const croppedAudioS3Key = await getAudioFileKey(videoId)// upload mp3 to file storageconst mp3URL = await uploadFile({Key: croppedAudioS3Key,Body: fs.createReadStream(tempFilePath)})// remove temp fileawait unlink(tempFilePath)await dbConnect()await VideoModel.updateOne({ _id: videoId },{ $set: { croppedAudioFileURL: mp3URL } })return mp3URL})
3. Rendering the video
The next step is to render the video using remote workers with beefy CPUs and GPUs.
We have a sub-queue that communicates with our own infrastructure. We send a job to the queue while Inngest allows us to wait until the job is done and handles new progress events.
const { videoFileURL, renderTime } = await step.run('render-video-to-s3',async () => {const outKey = await getVideoOutKey(videoId)const userBundle = bundles.find((p) => p.key === updatedVideo.user.bundle)if (!userBundle) {throw new NonRetriableError('no bundle assigned to user')}await dbConnect()const video = await VideoModel.findOne({_id: videoId}).populate('user')if (!video) {throw new NonRetriableError('no video found')}// attempt is provided by Inngest.// if video fails to render from the first attempt, we will pick different workerconst renderer = await determineRenderer(video, attempt)// CRF of the video based on user bundleconst constantRateFactor = determineRemotionConstantRateFactor(video.user.bundle)const renderPriority = await determineQueuePriority(video.user.bundle)logger.info(`Rendering Remotion video with renderer ${renderer} and crf ${constantRateFactor}`)const renderedVideo = await renderVideo({videoId: videoId,priority: renderPriority,renderOptions: {crf: constantRateFactor,concurrency: determineRemotionConcurrency(video),...(video.hdr && {colorSpace: 'bt2020-ncl'})},inputPropsOverride: {...video.videoSettings,videoFormat: video.videoFormat},renderer,audioURL: croppedMp3Url,startTime: 0,endTime: video.videoDuration,outKey,onProgress: async (progress) => {await VideoModel.updateOne({_id: videoId},{ $set: { renderProgress: progress, status: 'processing' } })}})return renderedVideo})
4. Invalidate CDN cache
There are some cases when video needs to be re-rendered. We host our videos on a CDN, but some times, the video needs to be re-rendered. To make sure CDN cache is always fresh, we purge it each time the video renders.
await step.run('create-invalidation-on-CloudFront', async () => {try {const { pathname: videoPathnameToInvalidate } = new URL(videoFileURL)return await invalidateCloudFrontPaths([videoPathnameToInvalidate,`/thumbnails/${videoId}.jpg`,`/thumbnails/${videoId}-square.jpg`])} catch (error) {sendTelegramLog(`Invalidation failed for ${videoId}: ${error.message}`)return `Invalidation failed, skipping: ${error.message}`}})
5. Updating video status to "ready"
After video is successfully rendered and we have obtained a URL, we set video status to "ready" and update renderTime
.
await step.run('update-video-status-to-ready', () =>Promise.all([VideoModel.updateOne({ _id: videoId },{$set: {status: 'ready',videoFileURL},$inc: {renderTime}})]))
6. Creating a video thumbnail
Finally, we also want to create a thumbnail for each video to show it in listings or use as a video posters.
await step.run('generate-thumbnail-and-upload-to-s3', async () => {const thumbnailFilePath = `${os.tmpdir()}/${videoId}-thumbnail.jpg`await execa(`ffmpeg`, ['-i',videoFileURL, // ffmpeg will grab input from URL'-vf','thumbnail=300','-frames:v', // only one frame'1',thumbnailFilePath])const thumbnailFileURL = await uploadFile({Key: `thumbnails/${videoId}.jpg`,Body: fs.createReadStream(thumbnailFilePath)})await dbConnect()await VideoModel.updateOne({ _id: videoId },{ $set: { thumbnailURL: thumbnailFileURL } })await unlink(thumbnailFilePath)})
7. Handling failures
We set a graceful flow termination strategy in the onFailure
function (please keep in mind that it's simplified for this blog post).
export const renderVideo = inngest.createFunction({name: 'Render video',id: 'render-video',cancelOn: [{event: 'banger/video.create',match: 'data.videoId'}],onFailure: async ({ error, event, step }) => {await dbConnect()const isStalled = RenderStalledError.isRenderStalledError(error)const updatedVideo = await step.run('Update video status to failed',() =>VideoModel.findOneAndUpdate({ _id: event.data.event.data.videoId },{$set: {status: isStalled ? 'stalled' : 'error',...(isStalled && { stalledAt: new Date() }),renderProgress: null}},{ new: true }).lean())invariant(updatedVideo, 'no video found')// refund user units if error is not recoverable// if it's stalled, we're going to recover it laterif (!isStalled) {await step.run('Refund user units', async () => {await UserModel.updateOne({_id: event.data.event.data.userId},{ $inc: { unitsRemaining: updatedVideo.videoDuration } })})}if (process.env.NODE_ENV === 'production') {const errorJson = _.truncate(JSON.stringify(event), {length: 3000})await sendTelegramLog(_.truncate(`🚨 Error while rendering video: ${error.message}\nEvent: ${errorJson}\n`,{ length: 3000 }))}Sentry.captureException(error)}},{ event: 'banger/video.create' },async ({ event, step, attempt, logger }) => {// ...})
Working with Inngest
Inngest makes the difficult parts easy.
For example, there's no simpler way to put it: I find the steps
concept mindblowing. I wished something like this was available back in 2019, when I was just starting out with BullMQ, Agenda.js, and other solutions. It's a really sweet abstraction. I also enjoy observability, so I can track each step and function run in one dashboard.
You can reach out to Igor on Twitter or listen to his music on Spotify.