Video Processing with Shrine and FFmpeg
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_persistprevents race conditions. If the user re-uploads while processing is running,atomic_persistdetects that the attachment changed and raisesShrine::AttachmentChangedinstead 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.