← All posts

Video Processing with Shrine and FFmpeg

December 15, 2021 rails

In the previous post we set up Shrine for image uploads with automatic thumbnails. This post extends that setup to handle video — transcoding uploads to multiple resolutions, extracting a poster frame, and handling the orientation issues that come with phone-recorded video.

Video processing is slow, so everything runs in a background job. The user uploads a file, gets immediate feedback, and the processed versions appear when they’re ready.

Dependencies

You’ll need FFmpeg installed on the server:

# macOS
brew install ffmpeg

# Ubuntu/Debian
sudo apt install ffmpeg

And the Ruby gems:

# Gemfile
gem "streamio-ffmpeg"
gem "shrine", "~> 3.0"
gem "image_processing", "~> 1.8"
bundle install

streamio-ffmpeg is a Ruby wrapper around FFmpeg’s command-line tools. It handles transcoding, metadata extraction, and screenshot capture.

The Video Uploader

app/uploaders/video_uploader.rb:

require "streamio-ffmpeg"
require "image_processing/mini_magick"

class VideoUploader < Shrine

    Attacher.validate do
        validate_max_size 500 * 1024 * 1024, message: "is too large (max 500 MB)"
        validate_mime_type %w[video/mp4 video/quicktime video/webm video/x-msvideo],
            message: "must be an MP4, MOV, WebM, or AVI"
    end

    Attacher.derivatives do |original|
        movie = FFMPEG::Movie.new(original.path)

        poster = Tempfile.new(["poster", ".jpg"])
        movie.screenshot(poster.path, seek_time: [movie.duration / 2, 1].max)
        thumb = ImageProcessing::MiniMagick.source(poster.path).resize_to_limit!(800, 800)

        transcoded = Tempfile.new(["video", ".mp4"])
        movie.transcode(transcoded.path, {
            video_codec: "libx264",
            audio_codec: "aac",
            custom: %W[-preset medium -crf 23 -movflags +faststart -pix_fmt yuv420p]
        })

        { poster: thumb, video: transcoded }
    end
end

The uploader validates the file, then generates two derivatives: a poster frame (captured at the midpoint) and a transcoded MP4. The FFmpeg options: -crf 23 balances quality vs. size, -movflags +faststart lets browsers start playing before the full download, and -pix_fmt yuv420p ensures broad compatibility.

The Model

class Lesson < ApplicationRecord

    include VideoUploader::Attachment(:video)
end

Showing Processing Status

While the job runs, you need to indicate that processing is in progress. Check whether derivatives exist:

<% if @lesson.video %>
  <% if @lesson.video_derivatives.any? %>
    <video controls poster="<%= @lesson.video_url(:poster) %>" class="w-100">
      <source src="<%= @lesson.video_url(:video) %>" type="video/mp4">
    </video>
  <% else %>
    <div class="alert alert-info">
      Video is being processed. This page will update when it's ready.
    </div>
  <% end %>
<% end %>

Tips

  • Process in a background job. Transcoding a 5-minute video to three resolutions takes 30–60 seconds. Don’t block the request.
  • Set a generous timeout. Make sure your job queue timeout is long enough for large files. A 500 MB video can take several minutes to process.
  • atomic_persist prevents race conditions. If the user re-uploads while processing is running, atomic_persist detects that the attachment changed and raises Shrine::AttachmentChanged instead of overwriting the new upload with old derivatives.
  • Store originals. Keep the original upload alongside the transcoded versions. You might need to regenerate derivatives later with different settings.
  • Disk space. Three resolutions plus the original can be 4× the upload size. Account for this in your storage budget.
  • FFmpeg installation in production. Make sure your Docker image or server has FFmpeg installed. It’s not a Ruby gem — it’s a system dependency.
Let’s work together! Tell Me About Your Project