Add QR Code of Video Source URL with FFMPEG (and Ruby)

by Carl Furrow — on  ,  , 

I have a tendency to forget where I downloaded videos from, and recently I thought of a somewhat simple way to do this: create a QR Code of the source URL and embed it into video file.

What you’ll need:

  • Ruby installed (I am using Ruby 3.0.2)
  • ImageMagick 7+
  • ffmpeg (I’m using 5.1.2)

Show me an example video!

Show me the code!

I’ll break down interesting parts of the code below.

#!/usr/bin/env ruby
require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'

  gem 'rmagick', '~> 5.1.0'
  gem 'chunky_png'
  gem 'rqrcode'
end

# This script takes a video file and a URL, and prepends a 5s video to the video that
# adds a QR code of the URL to the video. 
# It uses ffmpeg to generate a thumbnail of the video, then adds a QR code to the thumbnail.
# It then concatenates the thumbnail+QR code video with the original video.
# The QR code is added to the bottom right corner of the video.
# The QR code is 200x200 pixels
# 
# Usage:
#   ./add_qr_code_to_video.rb /path/to/video.mp4 https://example.com
#   #=> outputs a new file: /path/to/video-with-qr-code.mp4
class PrependQrCode
  include Magick

  QRCODE_WIDTH = 200
  FFMPEG_PATH = '/usr/local/bin/ffmpeg'

  def initialize(video_path:, url:)
    @video_path = video_path
    @url = url
    @video_dir = File.dirname(@video_path)
    # Get the video file's path and name, without the extension. We'll use this to name the thumbnail and QR code
    @video_basename = "#{@video_dir}/#{File.basename(@video_path, '.*')}" #=> /path/to/video/video-name
    @thumbnail_path = "#{@video_basename}-thumbnail.png" #=> /path/to/video/video-name-thumbnail.png
    @qr_code_path = "#{@video_basename}-qr-code.png" #=> /path/to/video/video-name-qr-code.png
    @new_video_path = "#{@video_basename}-with-qr-code.mp4" #=> /path/to/video/video-name-with-qr-code.mp4
  end

  def call
    @thumbnail = generate_thumbnail
    @qrcode = generate_qrcode
    add_qrcode_to_thumbnail

    add_qrcode_to_video

    delete_qrcode
    delete_thumbnail
  end

  # Create a 5 second loop of the thumbnail+QR code, then concatenate it with the original video.
  # 5s of QR Code + Original Video + 5s of QR Code
  def add_qrcode_to_video
    ffmpeg_command = <<~SHELL
    #{FFMPEG_PATH} -y -framerate 60 -loop 1 -t 5 \
    -i "#{@thumbnail_path}" \
    -i "#{@video_path}" \
    -f lavfi -t 0.1 -i "anullsrc" \
    -filter_complex "[0][2][1][1:a][0][2]concat=n=3:v=1:a=1[v][a]" \
    -vsync 2 \
    -map "[v]" \
    -map "[a]" \
    "#{@new_video_path}"
    SHELL

    system(ffmpeg_command)
  end

  # Add the QR code to the thumbnail image.
  # The QR code is added to the bottom right corner of the video, with a 20px padding
  # from the bottom and right edges.
  def add_qrcode_to_thumbnail
    padding = 20
    @thumbnail.composite!(@qrcode, @thumbnail.columns - QRCODE_WIDTH - padding, @thumbnail.rows - QRCODE_WIDTH - padding, OverCompositeOp)

    @thumbnail.write(@thumbnail_path)
  end

  def generate_thumbnail
    # Jump to frame 34 and take a screenshot, saving it to the thumbnail path
    `ffmpeg -i "#{@video_path}" -y -vf "select=34" -vframes 1 "#{@thumbnail_path}"`
    Image.read(@thumbnail_path).first
  end

  def generate_qrcode
    code = RQRCode::QRCode.new(@url)
    png = code.as_png(
      bit_depth: 1,
      border_modules: 1,
      color_mode: ChunkyPNG::COLOR_GRAYSCALE,
      color: 'black',
      file: nil,
      fill: 'white',
      module_px_size: 8,
      resize_exactly_to: false,
      resize_gte_to: false,
      size: QRCODE_WIDTH
    )
    File.binwrite(@qr_code_path, png.to_s)
    Image.read(@qr_code_path).first
  end

  def delete_qrcode
    File.delete(@qr_code_path)
  end

  def delete_thumbnail
    File.delete(@thumbnail_path)
  end
end

puts "Adding QR code for #{ARGV[0]} with URL #{ARGV[1]}"
PrependQrCode.new(video_path: ARGV[0], url: ARGV[1]).call

Let’s break it down

Generating a thumbnail

This is a pretty simple operation, since ffmpeg can do a lot of the heavy lifting for us:

  def generate_thumbnail
    # Jump to frame 34 and take a screenshot, saving it to the thumbnail path
    `ffmpeg -i "#{@video_path}" -y -vf "select=34" -vframes 1 "#{@thumbnail_path}"`
    # Use RMagick to read the file into memory
    Image.read(@thumbnail_path).first
  end

We pass the path of the video to ffmpeg, -i #{@video_path} and then use an ffmpeg video filter -vf called “select”, and pass the frame number we’d like to grab. In our case, I selected frame 34, but you can pick any number as long as it’s greater than -1 and less than the total frame count of the video. Then we tell ffmpeg we’d like to output a single frame from the video, now that we’re at frame 34: -vframes 1 and then save the output to the @thumbnail_path, which is a path to an image file we setup earlier (example: ‘/path/to/video/my-video-thumbnail.png’)

Generating a QR Code

Again, this is pretty simple because we’ll leverage a handy Ruby gem, ‘rqrcode’. I used an example directly from their README file.

whomwah/rqrcode

  def generate_qrcode
    code = RQRCode::QRCode.new(@url)
    png = code.as_png(
      bit_depth: 1,
      border_modules: 1,
      color_mode: ChunkyPNG::COLOR_GRAYSCALE,
      color: 'black',
      file: nil,
      fill: 'white',
      module_px_size: 8,
      resize_exactly_to: false,
      resize_gte_to: false,
      size: QRCODE_WIDTH
    )
    File.binwrite(@qr_code_path, png.to_s)
    # Use RMagick to read the file into memory
    Image.read(@qr_code_path).first
  end

Combining the QR Code and Thumbnail together

To combine the QR Code and Thumbnail images, I use RMagick’s #composite! method to insert the QR code into the thumbnail image. Currently, the size of the QRCode is fixed to 200x200 pixels, and will be placed 20 pixels from the right side of the thumbnail, and 20 pixels from the bottom of the thumbnail.

An improvement that could be made here is to set the size and positioning of the QR code relative based on the size of the thumbnail (and therefore, the video).

# Add the QR code to the thumbnail image.
# The QR code is added to the bottom right corner of the video, with a 20px padding
# from the bottom and right edges.
def add_qrcode_to_thumbnail
  padding = 20
  @thumbnail.composite!(@qrcode, @thumbnail.columns - QRCODE_WIDTH - padding, @thumbnail.rows - QRCODE_WIDTH - padding, OverCompositeOp)

  @thumbnail.write(@thumbnail_path)
end

Inserting the thumbnail (with QR Code) into the video

This is the most complex bit of the entire code sample above, but once again, ffmpeg does the work for us.

# Create a 5 second loop of the thumbnail+QR code, then concatenate it with the original video.
# 5s of QR Code + Original Video + 5s of QR Code
def add_qrcode_to_video
  ffmpeg_command = <<~SHELL
  #{FFMPEG_PATH} -y -framerate 60 -loop 1 -t 5 \
  -i "#{@thumbnail_path}" \
  -i "#{@video_path}" \
  -f lavfi -t 0.1 -i "anullsrc" \
  -filter_complex "[0][2][1][1:a][0][2]concat=n=3:v=1:a=1[v][a]" \
  -vsync 2 \
  -map "[v]" \
  -map "[a]" \
  "#{@new_video_path}"
  SHELL

  system(ffmpeg_command)
end

First off, we tell ffmpeg to overwrite any output files if they exist by passing in -y.

Then, we specify we’d like to setup our first input file (the thumbnail) to have a framerate of 60, loop one time, and last for 5 seconds. This is what creates the “pre-roll” video segment.

-framerate 60 -loop 1 -t 5 -i "#{@thumbnail_path}"

Next, we specify we’d like to also input the original video:

-i "#{@video_path}"

After that, we setup a third input, but this one is a bit strange. It’s a “null” source video input that lasts 0.1 seconds. While exploring ways to get ffmpeg to concat input files together, I saw a recommendation to use this “null source”, but since using it, I cannot find the original post that may have explained “why”.

-f lavfi -t 0.1 -i "anullsrc"

Here’s where things start to look more complicated, as we tell ffmpeg to apply the “concat” filter, which is what is used to combine our thumbnail, null source video, and original video together into a new output.

-filter_complex "[0][2][1][1:a][0][2]concat=n=3:v=1:a=1[v][a]"

Let’s start with this: [0][2][1][1:a][0][2]concat. That syntax is called “filtergraph”, and you can learn more about it here on ffmpeg’s site.. We are telling ffmpeg how we’d like to combine these videos, and the order they should appear in when we output the new video file.

  • [0] is the first input we defined, which is our thumbnail.
  • [1] is the original video
  • [1:a] is the audio stream from the original video (and [1:v] would be the video stream)
  • [2] is the “null source” video

Given that, you can see that we’re telling ffmpeg to concatenate our inputs in this order:

  1. thumbnail [0]
  2. null source [2]
  3. original video+audio [1][1:a]
  4. thumbnail [0]
  5. null source [2]

After that, we’re giving the concat more parameters: concat=n=3:v=1:a=1[v][a]

The n argument is the number of segments being concatenated. We’ve given concat 3 (thumbnail, null source, original video) so we’ve set n=3.

The v argument tells the concat filter how many output video streams we’d like to generate. Since we want all of the inputs to be concatenated into a single video, we set v=1.

The a argument is similar to the v argument: we specify how many output audio streams to create, and again, we only want one, a=1

The last two elements of our concat filter above are [v][a], this is ffmpeg’s filtergraph syntax, and it is telling ffmpeg we’d like to save the video stream output from concat to a new variable (called a “link”), and we are calling it “v”. And as you can guess, we are also telling concat to output the audio stream to a variable or “link” called “a”. We’ll use these next!

Now that we’ve told ffmpeg how we’d like things concatenated, we now need to define the output details:

-vsync 2 \
-map "[v]" \
-map "[a]" \
"#{@new_video_path}"

The -vsync argument is telling ffmpeg how we’d like the original source frames and timestamps handled in the output file. -vsync 0 is a straight pass-through, without any modifications. -vsync 1 duplicates frames (as well as drops some frames) of the source to achieve a constant framerate. -vsync 2 is a way to try and prevent 2 frames from having the same timestamp (e.g. duplicate frames in the output). You can read more about the -vsync parameter here: https://ffmpeg.org/ffmpeg.html

The -map argument tells ffmpeg what video and audio stream we’d like to use in our output. As you can see, we specify our concatenated video and audio output variables (aka “links”) of [v] and [a] from the previous step.

Lastly, we tell ffmpeg where to save the new video, which is stored in a Ruby variable called @new_video_path

That’s it!

Hopefully this was a mostly-painless introduction to ffmpeg for those that have not used it before. There are so many other features of ffmpeg beyond what I’ve shown above, most commonly, ffmpeg is used to extract clips from larger videos, or to re-encode from one format (Quicktime mov) to another (H265 + mp4), etc.

References


Carl Furrow's photo Author

Carl Furrow

hello@carlfurrow.com

Addicted to learning and looking to master the art of solving problems through writing code, while occasionally yelling at computer screens.