DevGang
Авторизоваться

Глубокое погружение в конвейер рендеринга видео

Всем привет! В этом посте я расскажу вам о нашем конвейере рендеринга видео, построенном с помощью Inngest, на котором работает banger.show.

banger.show — это приложение для создания видео для музыкантов, диджеев и лейблов. Оно позволяет людям из музыкальной индустрии создавать потрясающие визуальные активы для своей музыки.

Создание видео для вашей новой песни занимает всего несколько минут, и вам не нужно устанавливать или изучать сложное программное обеспечение, потому что banger.show работает в вашем браузере!

Быстрое выполнение фоновой обработки

В banger.show мы выполняем много фоновой обработки. Управление состояниями рендеринга, генерация визуальных активов в фоновом режиме и даже обработка процесса рендеринга на удаленных распределенных рабочих местах.

Мы выбрали Inngest, потому что нет лучшего способа обработки фоновых заданий, если вы используете Next.js без собственного сервера. Для нас это основной «контроллер потока», несмотря на то, что для обработки заданий на нашей инфраструктуре мы используем простое решение для работы с очередями на базе Redis. Он позволяет нам оркестровать, наблюдать и абстрагироваться от процессов нижнего уровня, например:

  • Мы не должны делегировать все задачи пострендера машине рендеринга видео, но должны иметь некоторый поток, в котором главный сервер может получать результаты рендеринга от рабочих и что-то с ними делать.
  • Мы можем наблюдать каждый шаг и данные о его результатах на приборной панели.
  • Мы можем справиться с чрезвычайными ситуациями, когда вся наша инфраструктура выходит из строя, и нам нужно создать резервные рабочие места на AWS.

Прежде чем приступить к работе, давайте рассмотрим наш конвейер рендеринга видео на высоком уровне:

  • Сначала приложение получает от пользователя загруженный проект.
  • Затем он преобразует звук для более эффективного рендеринга.
  • Затем проект отправляется на машину рендеринга, которая контролирует ход выполнения, обновляет статусы, обрабатывает повторные попытки ошибок и случаи «остановки рендеринга».
  • Наконец, после завершения рендеринга необходимо выполнить ряд задач, таких как аннулирование кэша CDN, создание эскизов видео или отправка электронного письма пользователю, когда видео будет готово.

Теперь давайте рассмотрим некоторые части конвейера рендеринга видео.

1. Обновление состояния рендеринга

Первым шагом в конвейере рендеринга является обновление статуса рендеринга и установка пользовательских кредитов в режиме ожидания. Здесь мы используем стандартный параллелизм Inngest.

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')

      // Simplified
      await UserModel.updateOne(
        { _id: video.user._id },
        { $inc: { unitsRemaining: -video.videoDuration } }
      )
      return video
    })
})

2. Обрезка аудиофайла

В banger.show пользователь выбирает фрагмент песни, чтобы создать для него «видеозапись». В фоновом режиме мы:

  • Обрезаем аудиофайл по выбору пользователя.
  • Конвертируем файл в формат mp3 для экономии места на диске и оптимальной совместимости.

Давайте посмотрим на это в коде.

const croppedMp3Url = await step.run(
  'trim-audio-and-convert-to-mp3',
  async () => {
    // create temporary file
    const 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 time
      tempFilePath
    ])

    const croppedAudioS3Key = await getAudioFileKey(videoId)

    // upload mp3 to file storage
    const mp3URL = await uploadFile({
      Key: croppedAudioS3Key,
      Body: fs.createReadStream(tempFilePath)
    })

    // remove temp file
    await unlink(tempFilePath)

    await dbConnect()

    await VideoModel.updateOne(
      { _id: videoId },
      { $set: { croppedAudioFileURL: mp3URL } }
    )

    return mp3URL
  }
)

3. Рендеринг видео

Следующий шаг — рендеринг видео с помощью удаленных операторов с мощными CPU и GPU.

У нас есть под-очередь, которая взаимодействует с нашей собственной инфраструктурой. Мы отправляем задание в очередь, а Inngest позволяет нам ждать, пока задание будет выполнено, и обрабатывает новые события прогресса.

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 worker
    const renderer = await determineRenderer(video, attempt)

    // CRF of the video based on user bundle
    const 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. Аннулирование кэша CDN

Бывают случаи, когда видео необходимо перерендерить. Мы размещаем наши видео на CDN, но иногда видео приходится рендерить заново. Чтобы кэш CDN всегда был свежим, мы очищаем его каждый раз, когда видео рендерится.

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. Обновление статуса видео до статуса «готово»

После успешного рендеринга видео и получения URL-адреса мы устанавливаем статус видео на «готово» и обновляем renderTime.

await step.run('update-video-status-to-ready', () =>
  Promise.all([
    VideoModel.updateOne(
      { _id: videoId },
      {
        $set: {
          status: 'ready',
          videoFileURL
        },
        $inc: {
          renderTime
        }
      }
    )
  ])
)

6. Создание миниатюры видео

Наконец, мы также хотим создать миниатюру для каждого видео, чтобы показывать его в объявлениях или использовать как видеопостеры.

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. Устранение неисправностей

Мы задаем стратегию изящного завершения потока в функции onFailure (пожалуйста, имейте в виду, что она упрощена для этой статьи в блоге).

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 later
      if (!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}\n
          Event: ${errorJson}\n`,
            { length: 3000 }
          )
        )
      }

      Sentry.captureException(error)
    }
  },
  { event: 'banger/video.create' },
  async ({ event, step, attempt, logger }) => {
    // ...
  })

Почему я выбрал Inngest?

Inngest делает сложные детали простыми.

Например, проще некуда: Я нахожу концепцию шагов умопомрачительной. Я бы хотел, чтобы нечто подобное было доступно в 2019 году, когда я только начинал работать с BullMQ, Agenda.js и другими решениями. Это действительно приятная абстракция. Мне также нравится наблюдаемость — я могу отслеживать каждый шаг и выполнение функции на одной панели.

Благодарю за почтение!

Источник:

#JavaScript
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу