DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Record video in Rails with Stimulus

This article was originally published on the Rails Designer blog


Early last year I helped a team move from jQuery to Hotwire (you will be surprised how many teams still use jQuery! ❀️). It was a fun time (no, really!). One of the more interesting parts was moving from a jQuery plugin for video recording to Stimulus. Today I want to show the outline of how I did that.

The MediaRecorder API captures video directly in the browser. Webcam, screen sharing or both at once (picture-in-picture style). No external services, no complicated setup. Just modern browser APIs and a well-organized Stimulus controller. Exactly what I like.

Here is what you get:

  • record from your webcam;
  • record your screen;
  • record both in picture-in-picture mode (webcam overlay on screen recording);
  • preview the recording before saving;
  • save it as an Active Storage attachment.

The foundation is simple: a Rails app with a Presentation model that has an attached video. Create a presentation, record it, save it. Done. The interesting part?

This article goes over the interesting parts, for the complete code, see this GitHub repo.

The view structure

Here is what the recording interface looks like:

<div data-controller="recorder">
  <h2>New Recording</h2>
  <div>
    <button type="button" data-action="click->recorder#selectMode" data-recorder-mode-param="webcam">Webcam</button>
    <button type="button" data-action="click->recorder#selectMode" data-recorder-mode-param="screen">Screen</button>
    <button type="button" data-action="click->recorder#selectMode" data-recorder-mode-param="pip">Picture-in-picture</button>
  </div>

  <video data-recorder-target="preview" width="640" height="480"></video>

  <div>
    <button type="button" data-recorder-target="startButton" data-action="recorder#start">Start recording</button>
    <button type="button" data-recorder-target="stopButton" data-action="recorder#stop" disabled>Stop recording</button>
  </div>

  <h2>Preview</h2>
  <video data-recorder-target="video" width="640" height="480" controls></video>

  <%= form_with model: @presentation, data: { recorder_target: "form" } do |form| %>
    <%= form.file_field :video, data: { recorder_target: "videoInput" }, hidden: true %>

    <button type="button" data-action="click->recorder#save">
      Save recording
    </button>
  <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode

Three mode buttons, a preview video (what you see while recording), a recorded video (playback after stopping) and a form to save it. Yes, not pretty, but it works!

One controller to record them all

The recorder controller starts with the setup:

// app/javascript/controllers/recorder_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["preview", "startButton", "stopButton", "video", "form", "videoInput"]
  static values = { mode: { type: String, default: "webcam" } }

  connect() {
    this.recorder = null
    this.recordedData = []

    this.webcamStream = null
    this.screenStream = null

    this.recordedBlob = null
  }

  disconnect() {
    if (this.webcamStream) this.webcamStream.getTracks().forEach(track => track.stop())
    if (this.screenStream) this.screenStream.getTracks().forEach(track => track.stop())
  }
}
Enter fullscreen mode Exit fullscreen mode

The targets point to the video elements, buttons and form. The mode value tracks which recording type is active (webcam, screen or pip). In connect() the state gets initialized. In disconnect() any active media streams get cleaned up. Always clean up your streams! 🧹

Modes

selectMode({ params: { mode } }) {
  this.modeValue = mode
}
Enter fullscreen mode Exit fullscreen mode

Click a mode button and it updates the modeValue. Simple! Stimulus params make this elegant.

Is this thing on?

async start() {
  this.recordedData = []
  const stream = await this.#mediaStream()

  this.#toggleButtons()

  this.#startPreview(stream)
  this.#setupRecorder(stream)

  this.recorder.start()
}
Enter fullscreen mode Exit fullscreen mode

Clear any previous recording data, get the media stream for the selected mode, toggle the buttons (disable start, enable stop), show the preview, set up the recorder and start recording. Each step is a small, focused method.

getUserMedia magic

async #mediaStream() {
  const streamMethods = {
    webcam: () => navigator.mediaDevices.getUserMedia({ video: true, audio: true }),
    screen: () => navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }),
    pip: async () => {
      this.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })
      this.webcamStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })

      return this.#combinedStream(this.screenStream, this.webcamStream)
    }
  }

  return await streamMethods[this.modeValue]()
}
Enter fullscreen mode Exit fullscreen mode

This is where the magic happens, as they say! getUserMedia handles webcam access and getDisplayMedia handles screen sharing. Both are part of the Media Capture and Streams API.

For picture-in-picture mode, both streams get grabbed and combined. More on that in a moment.

Setting up the MediaRecorder

#setupRecorder(stream) {
  this.recorder = new MediaRecorder(stream)
  this.recorder.ondataavailable = (event) => this.#dataAvailable(event)
  this.recorder.onstop = () => this.#recordingStopped()
}

#dataAvailable(event) {
  if (event.data.size > 0) this.recordedData.push(event.data)
}
Enter fullscreen mode Exit fullscreen mode

The MediaRecorder API takes a media stream and records it. As data becomes available (in chunks), it gets pushed into the recordedData array. When recording stops, the final output gets handled.

Stop… saving time

stop() {
  this.recorder.stop()

  this.#toggleButtons()
  this.#cleanupStreams()
}

#recordingStopped() {
  this.recordedBlob = new Blob(this.recordedData, { type: "video/webm" })
  this.videoTarget.src = URL.createObjectURL(this.recordedBlob)

  this.#clearPreview()
  this.#cleanupStreams()
}
Enter fullscreen mode Exit fullscreen mode

When you stop recording, a Blob gets created from the recorded chunks and displayed in the preview video using URL.createObjectURL. This gives you a playable URL for the blob.

Then save it:

save() {
  if (!this.recordedBlob) return

  const file = new File([this.recordedBlob], "recording.webm", { type: "video/webm" })
  const dataTransfer = new DataTransfer()
  dataTransfer.items.add(file)

  this.videoInputTarget.files = dataTransfer.files

  this.formTarget.requestSubmit()
}
Enter fullscreen mode Exit fullscreen mode

The blob gets converted into a File object, added to a DataTransfer object (this is how you programmatically set file input values) and the form submits. Rails handles the rest with Active Storage (in a real app you likely want to use Active Storage's Direct Upload feature).

Combine webcam + screen

This is the coolest part, I think! To combine screen and webcam streams, a canvas does the work:

#combinedStream(screenStream, webcamStream) {
  const canvas = document.createElement("canvas")
  const canvasContext = canvas.getContext("2d")

  canvas.width = 1280
  canvas.height = 720

  const screenVideo = document.createElement("video")
  const webcamVideo = document.createElement("video")

  screenVideo.srcObject = screenStream
  webcamVideo.srcObject = webcamStream

  screenVideo.play()
  webcamVideo.play()

  const draw = () => {
    canvasContext.drawImage(screenVideo, 0, 0, canvas.width, canvas.height)
    canvasContext.drawImage(webcamVideo, canvas.width - 320, canvas.height - 240, 320, 240)

    requestAnimationFrame(draw)
  }

  draw()

  return canvas.captureStream(30)
}
Enter fullscreen mode Exit fullscreen mode

Lots going on here, but I think it is still followable (sneak peek: an article around canvas is coming 🀫). A canvas gets created, the screen recording gets drawn as the background and the webcam feed gets overlaid in the bottom-right corner. The requestAnimationFrame loop keeps it updating smoothly (it is an API you have read about here before). Then captureStream turns the canvas into a media stream at 30 FPS.

Pretty slick! 😎

Helper methods

A few small methods keep things tidy:

#startPreview(stream) {
  this.previewTarget.srcObject = stream
  this.previewTarget.play()
}

#toggleButtons() {
  this.startButtonTarget.disabled = !this.startButtonTarget.disabled
  this.stopButtonTarget.disabled = !this.stopButtonTarget.disabled
}

#clearPreview() {
  this.previewTarget.srcObject = null
}

#cleanupStreams() {
  if (this.webcamStream) {
    this.webcamStream.getTracks().forEach(track => track.stop())
    this.webcamStream = null
  }

  if (this.screenStream) {
    this.screenStream.getTracks().forEach(track => track.stop())
    this.screenStream = null
  }
}
Enter fullscreen mode Exit fullscreen mode

I like these kins of small methods where each does one thing. Preview the stream. Toggle buttons. Clear the preview. Clean up streams. This makes the main methods easier to read.

On organizing Stimulus controllers

Notice how the public methods (start, stop, save, selectMode) sit at the top? Then all the private methods (prefixed with #) below? When you open this file, you immediately see what the controller does. Start recording. Stop recording. Save recording. Select mode. The implementation details are tucked away below.

Compare this to alphabetically sorted methods or mixing public and private. Much harder to scan. The order matters for readability. Put the interface first, the implementation second. It is something I wrote about before and more extensively in JavaScript for Rails Developers. Small organizational choices like this make your code feel more professional.


And there you have it! A complete video recording feature using modern browser APIs and a well-organized Stimulus controller. No external dependencies (like the jQuery plugin that started this work), no complicated setup. Just clean JavaScript. Isn't it pretty?

Give it a try and let me know how it works for you! Can just write below, no need to send a video message! πŸ˜…

Top comments (0)