Direct Uploads Error Storing Video.mov. Status: 0 Active Storage
Building a video converter with Rails 6 and FFmpeg
28 Apr 2021
Today's project pulls together a number of built-in Rails tools, plus a couple of incredibly useful open source projects to build a web application that can convert user-uploaded video files to MP4.
To demonstrate some of the modern parts of the standard Rail stack, we're going to go further than just building a converter. Nosotros will enhance the user feel during the upload and conversion process with Active Storage
to handle directly file uploads, Activity Cable
for real-fourth dimension updates on the video conversion, and Stimulus
to dynamically update the DOM without needing a heavy Javascript framework. When we're finished, we'll take an ugly but fully functional web application that allows a user to upload a video file. After the file is uploaded, we'll convert the video to MP4 if needed, and so display that converted video to the user. It will look something like this:
This guide assumes that you are comfy with Ruby on Rails and familiar with Agile Storage and Stimulus, merely y'all do non need to exist an adept on whatsoever of these tools to get value from this guide. We'll commencement with a brand new Rails 6 projection, however, you lot can follow along with an existing Rails 6 projection if you prefer.
If you use an existing projection you lot may need to complete additional setup steps for Stimulus, Activity Cable, and Active Storage that won't be covered in this guide.
Project setup
To outset, let'southward create our Rail application using webpack and Stimulus, install Active Storage, add a User scaffold to work from, and drift our database.
All standard Rails stuff here, hopefully zilch totally new yet!
track new upload_progress --webpack =stimulus --skip-java --database =postgresql -T cd upload_progress rails db:create runway g scaffold User name:string rails active_storage:install rails db:migrate
At this point, you can boot your track server with rail s
and visit localhost:3000/users to run across the scaffold working equally expected. Create a few users if you want. There are no rules here.
Now that the projection is setup, permit's go into the fun stuff.
Uploading files with Active Storage
Our user will have a profile video, which nosotros'll utilize equally the base of operations for exploring video conversion afterward on in this guide.
To add a contour video, update the User
model to add a profile_video
backed by Active Storage.
# app/models/user.rb class User < ApplicationRecord has_one_attached :profile_video stop
Thanks to the power of Active Storage we can at present merely add together the profile video to our controller and views and commencement uploading files right abroad.
Head to the users grade and add together a new course field for the video:
<!-- app/views/users/_form.html.erb --> < %= form_with ( model: user ) do | form | % > < % if user.errors.any ? % > <div id= "error_explanation" > <h2>< %= pluralize ( user.errors.count , " mistake ") % > prohibited this user from being saved:</h2> <ul> < % user.errors.each exercise | error | % > <li>< %= error.full_message % ></li> < % stop % > </ul> </div> < % end % > <div class= "field" > < %= class.label :name % > < %= form.text_field :proper noun % > </div> <div class= "field" > < %= class.label :profile_video % > < %= form.file_field :profile_video % > </div> <div class= "deportment" > < %= grade.submit % > </div> < % end % >
Then update the UsersController
to add together profile_video to the existing user_params
method.
# app/controllers/users_controller.rb def user_params params . require ( :user ). let ( :name , :profile_video ) end
Finally, nosotros desire to be able to see the video after nosotros've uploaded information technology. Head to app/views/users/evidence
and add a <video>
chemical element to the folio.
<!-- app/views/users/show.html.erb --> <video controls > <source src= "<%= url_for(@user.profile_video) %>" > </video>
Try information technology out and make sure that uploading videos works as expected by creating a new user and attaching a video file to the profile_video field.
Stunning. You're a star.
While this works, we can make the upload experience a little improve.
Kickoff - when the file upload dialog opens, the user tin choose any type of file they like. Let's add a guardrail to just allow video files to be selected in the upload dialog.
<!-- app/views/users/_form_.html.erb --> < %= grade.file_field :profile_video , accept: " video / * " % >
Note that in a real app accept
is a squeamish user feel gain but it does not validate the user'due south input. You should ever validate the file type on the server! A malicious or curious user tin can easily change the accept attribute in the browser. For today nosotros tin can get by with field-level validation on the customer.
Adding directly uploads and an upload progress bar
When a user uploads a big file direct to your servers, they are going to discover the asking takes a long fourth dimension and, in product, it may ofttimes fourth dimension out, block other requests, and cause all kinds of headaches.
Nosotros can prevent all of those issues with the straight upload feature congenital in to Active Storage. Direct upload sends files directly from the user'southward client to your chosen deject storage provider. Directly uploads speed upward the upload and keeping the request from timing out or backing upward your servers.
Allow'southward add straight upload to the file field and, as a bonus, add an upload progress bar for the user.
First, confirm that you have ActiveStorage'due south javascript added to your awarding.js
file.
// application.js // If y'all started with a new Runway project, these lines should already exist in application.js. If not, add them import * as ActiveStorage from " @rail/activestorage " ActiveStorage . showtime ()
Now, we need to tell our file field to use the directly upload javascript:
<!-- app/views/users/_form_.html.erb --> < %= form.file_field :profile_video , have: " video / * ", direct_upload: truthful % >
Refresh your page and confirm that everything works exactly as it did before we added straight upload. Again, Runway makes things actually easy.
While our file upload is working and we could exit it every bit it is, in the real earth uploads can take a while, specially when you're handling large video files. Giving the user feedback as their file uploads is a nice UX win. Allow'south add a progress bar to track the file upload using a few sprinkles of Stimulus.
To track upload progress, nosotros'll mind for the direct-upload:progress
result and, each time that event is emitted we'll employ the information in the effect to update a progress bar in the UI.
We'll start with our Stimulus controller, which looks like this:
// app/javascript/controllers/upload_progress_controller.js import { Controller } from ' stimulus ' export default course extends Controller { static targets = [ " progress " , " progressText " , " progressWidth " ] initialize () { } connect () { this . element . addEventListener ( " direct-upload:progress " , this . updateProgress . bind ( this )) this . chemical element . addEventListener ( " direct-upload:error " , consequence => { effect . preventDefault () const { id , error } = upshot . detail console . log ( error ) }) } showProgress () { this . progressTarget . style . display = " block " } updateProgress () { const { id , progress } = event . detail this . progressWidthTarget . style . width = ` ${ Math . round ( progress )} %` this . progressTextTarget . innerHTML = ` ${ Math . round ( progress )} % complete` } disconnect () { this . element . removeEventListener ( " direct-upload:progress " , this . updateProgress ) } }
Allow'south suspension this downwardly a footling fleck - I'm bold you've got a piffling bit of familiarity with Stimulus here, if you don't, the Stimulus Handbook is a great starting point.
connect
is used to setup our event listener and subscribe to the events we care virtually - in this case, direct-upload:progress
and directly-upload:fault
Our progress event listener calls updateProgress
and then uses the progress
data from the upshot to update the UI elements that make upward our progress bar.
Next nosotros'll add together our progress bar HTML and connect our Stimulus controller to the DOM.
<!-- app/views/users/_form_.html.erb --> < %= form_with ( model: user , html: { data: { controller: " upload-progress " } } ) practice | form | % > <!-- snipped class fields --> <div manner= "display: none;" information-upload-progress-target= "progress" > <div>Uploading your video to our servers: <span id= "progressText" data-upload-progress-target= "progressText" >Warming upward...</bridge></div> <div id= "progress-bar" mode= "background: gray; position: relative; width: 200px; pinnacle: 20px;" > <div id= "progressWidth" data-upload-progress-target= "progressWidth" style= "width: 0%; pinnacle: 100%; position: absolute; background: light-green;" > </div> </div> </div> <div class= "deportment" > < %= form.submit " Salvage ", data: { action: " click- >upload-progress#showProgress" } %> </div> < % end % >
There's a lot here, let'south pace through the updates individually.
First, nosotros connect our Stimulus controller to the DOM past adding data: { controller: "upload-progress" }
to our form_with
Then, we add our progress bar HTML. Don't mind all the inline styles, yous can use CSS if you like.
<div mode= "display: none;" information-upload-progress-target= "progress" > <div>Uploading your video to our servers: <bridge id= "progressText" information-upload-progress-target= "progressText" >Warming upwards...</span></div> <div id= "progress-bar" style= "background: gray; position: relative; width: 200px; meridian: 20px;" > <div id= "progressWidth" information-upload-progress-target= "progressWidth" style= "width: 0%; height: 100%; position: absolute; background: green;" > </div> </div> </div>
The progress bar is hidden when the page loads and we use Stimulus targets on our progress bar container, progress text, and progress width elements. The Stimulus controller uses these targets to update the DOM every bit directly-upload:progress
events are emitted.
Finally, on the course'south submit push, nosotros listen for a click. When the grade submit button is clicked, we call upload-progress#showProgress
to remove the hidden style from the progress bar container.
Adding the video converter service
Now we've got our profile videos uploading nicely and nosotros are informing users of the progress throughout.
Smashing progress, but nosotros are accepting and displaying any video blazon and our goal is to build a video converter so that we have a predictable video type on profile videos. For this tutorial, let's say that our requirements are that all uploaded videos must end up as MP4s.
Before we dive in to the details, let's review what nosotros want to accomplish at a loftier level. We want to:
- Permit a user to upload a video file through the User Profile form
- When a video file is uploaded, check the video's content type
- If the video is already an mp4 file, nosotros don't need to convert the video - our work is done
- If the video is not an mp4 file, we want to transcode the video from its original type to .mp4
Then, how do we accomplish footstep four? Converting a video on the fly sounds complicated, right?
Enter FFmpeg, an incredibly popular, open up source solution for working with video (and audio, if that'south your affair).
While nosotros can work with FFmpeg directly, we're going to utilise a gem to make our interaction with FFmpeg a little simpler: streamio-ffmpeg
Allow's start by calculation the gem to our project with parcel add streamio-ffmpeg
and installing ffmpeg on our system with brew install ffmpeg
on a Mac. Other installation options can be institute here
With FFmpeg ready, let's add together a service to handle video conversion with mkdir app/services && affect app/services/video_converter.rb
Here'southward what the service looks similar:
# app/services/video_converter.rb class VideoConverter def initialize ( user_id ) @user = User . find ( user_id ) stop def convert! process_video cease private def process_video @user . profile_video . open up ( tmpdir: "/tmp" ) do | file | movie = FFMPEG :: Film . new ( file . path ) path = "tmp/video- #{ SecureRandom . alphanumeric ( 12 ) } .mp4" movie . transcode ( path , { video_codec: 'libx264' , audio_codec: 'aac' }) @user . profile_video . adhere ( io: File . open ( path ), filename: "video- #{ SecureRandom . alphanumeric ( 12 ) } .mp4" , content_type: 'video/mp4' ) end end end
The important role of the service is the process_video
method, so permit'south zoom in there.
Starting time, we open the existing profile video attached to the user, then we can access the video's path.
@user.profile_video.open up(tmpdir: "/tmp") practice |file|
Next, we create a Motion picture
object with the streamio-ffmpeg
gem, using the original file uploaded by the user, which we're going to transcode soon.
movie = FFMPEG::Motion picture.new(file.path)
The path variable assignment is the location where nosotros will create the new, transcoded video. Using this new path and the Picture show
object, nosotros call the transcode
method from the streamio-ffmpeg
precious stone.
Finally, nosotros adhere the newly created video to the user, replacing the video that was previously uploaded. Easy!
@user.profile_video.attach(io: File.open(path), filename: "video-#{SecureRandom.alphanumeric(12)}.mp4", content_type: 'video/mp4')
Adding a background task
One more than step before we can showtime converting uploaded videos. Working with video tin be time and resource intensive. We don't want to convert a video during a folio turn or block our application servers with expensive video processing.
Let's add a job that we tin use to enqueue video processing jobs in the background. For this tutorial, we'll apply ActiveJob with the default :async
adapter, just in production you'll desire to use a real background processor.
Add the job with rails g job convert_video
Our chore receives a user id and calls the video converter service for that user, like this:
class ConvertVideoJob < ApplicationJob queue_as :default def perform ( user_id ) VideoConverter . new ( user_id ). convert! cease end
Converting uploaded videos
Now we're ready to convert videos. In our UsersController
, nosotros'll enqueue the background job in our create and update methods, after the user is saved.
# app/controllers/users_controller.rb def create @user = User . new ( user_params ) respond_to do | format | if @user . save ConvertVideoJob . perform_later ( @user . id ) # snip response boilerplate terminate end end def update respond_to do | format | if @user . update ( user_params ) ConvertVideoJob . perform_later ( @user . id ) # snip response boilerplate finish end end
Now when nosotros upload a profile video, our VideoConverter
service volition convert the video to mp4 and supercede the uploaded video with the newly converted video.
Try it out by uploading whatsoever non-mp4 video from the users form. In your server logs y'all should see output that looks like this if everything is working every bit expected:
INFO -- : Transcoding of /tmp/ActiveStorage-22-20210428-10611-1wvew4g.mov to tmp/video-9ZrvVFnAZZTJ.mp4 succeeded
But expect a minute. Transcoding can accept a while and information technology happens in the background, and so when I upload a video, the show page will render the non-transcoded video until after transcoding is complete. That's going to cause all kinds of weird bugs, correct?
Right.
Permit'due south get fancy with Action Cablevision, plus a few more sprinkles of Stimulus to track video conversion progress and return the converted video without a folio plough.
Using Activity Cablevision to broadcast video updates
First let's talk through the trouble again at a loftier level. We desire to reach something like this:
- When a video is uploaded, bank check to see if it is an mp4
- If it isn't, flag the video as needs conversion
- If the video is flagged as needs conversion, don't render the video chemical element on the user's evidence page
- If a video is being processed, communicate that to the user
- If the video has been processed, or did not need to be candy, render the video element on the show page
Let'southward start with the logic nosotros skipped in the terminal section to check if the video needs to be transcoded. Since there'due south no indicate in converting an mp4 to an mp4, we should check the uploaded video'due south content blazon before doing whatever processing.
If the content type is video/mp4, then we don't need to transcode the video. If the content type is annihilation else, flag the video and get-go converting.
Before we write the logic code, let'southward add together a boolean value to our User model to track whether the profile video needs to exist converted:
track g migration AddConvertVideoToUsers convert_video:boolean
Drift your database before moving on rails db:migrate
Now we tin can add the logic to our controller. In the real world, the controller probably isn't the right place for this code, simply we're here to learn, then let's stay focused.
# app/controllers/users_controller.rb def create @user = User . new ( user_params ) respond_to do | format | if @user . save update_conversion_value ConvertVideoJob . perform_later ( @user . id ) # snip render logic finish end end # PATCH/PUT /users/one or /users/1.json def update respond_to practise | format | if @user . update ( user_params ) update_conversion_value ConvertVideoJob . perform_later ( @user . id ) # snip render logic end cease end private # snip def update_conversion_value return unless @user . profile_video needs_conversion = @user . profile_video . content_type != "video/mp4" @user . update_column ( :convert_video , needs_conversion ) end
The slightly clunky update_conversion_value
method checks the content blazon of the profile video and assigns the appropriate value to the convert_video column.
Now that we've got this logic in place, nosotros tin head into our converter service and put that logic to use.
# app/services/video_converter.rb def convert! return unless @user . convert_video? process_video cease
This modify ensures that our video converter doesn't catechumen the video unless it has been flagged for conversion.
Adjacent we want to update the value of convert_video
afterwards we've converted non-mp4 videos. Nosotros tin do that with a new update_needs_conversion
method in our service:
# app/services/video_converter.rb grade VideoConverter def convert! return unless @user . convert_video? process_video update_needs_conversion terminate private # Snip def update_needs_conversion @user . update_column ( :convert_video , faux ) end end
Now when nosotros upload a video, the converter will return without doing annihilation if the video is an mp4, otherwise information technology will convert the video then update the convert_video
flag on the user to imitation.
With this flag in place, we can add in our Action Cable and Stimulus magic to communicate a video's conversion status to the user so they can meet their uploaded video without refreshing the page.
Let'south think our goal for this part of the project. Nosotros want to:
- Check the video'south conversion condition when nosotros render the user testify page
- If the video is converted, display the video in a video chemical element
- If the video is not converted, display a placeholder for the video and communicate the video's conversion condition to the user
- When the video finishes converting, automatically update the content of the user's prove page to display the video in a video element
Let'due south dig in.
Outset, nosotros need to generate an Action Cable channel to broadcast from. As usual, Rails comes with a congenital in converter for this. Use track thou channel VideoConversion
to generate the new aqueduct
Update the generated video_conversion_channel.rb
file to expect similar this:
class VideoConversionChannel < ApplicationCable :: Aqueduct def subscribed stream_from "video_conversion_ #{ params [ :id ] } " end terminate
This channel is responsible for broadcasting the progress of the video conversion process, updating a percentage consummate element in the UI.
Next, allow's add the Stimulus controller that will listen for events from this aqueduct and update the UI as it receives them.
First touch on app/javascript/controllers/conversion_progress_controller.js
And then:
// app/javascript/controllers/conversion_progress_controller.js import { Controller } from " stimulus " ; import consumer from " channels/consumer " ; consign default class extends Controller { static targets = [ " progressText " ] initialize () { this . subscription = consumer . subscriptions . create ( { channel : " VideoConversionChannel " , id : this . element . dataset . id , }, { connected : this . _connected . demark ( this ), asunder : this . _disconnected . bind ( this ), received : this . _received . demark ( this ), } ); } _connected () {} _disconnected () {} _received ( information ) { this . updateProgress ( information * 100 ) } updateProgress = ( progress ) => { allow progressPercent = '' if ( progress >= 100 ) { progressPercent = " 100% " } else { progressPercent = Math . round ( progress ) + " % " } this . progressTextTarget . innerHTML = progressPercent } }
This controller contains a lot of Action Cable average, don't permit it overwhelm you.
The important parts are the channel
we subscribe to in the initialize
method and the _received
method. When a new message is broadcast on the aqueduct that the user is subscribed to (from the id in the initialize
method), _received
calls updateProgress
which updates the DOM with the progress value circulate by Activeness Cable.
Let's wire this controller up to our HTML and start to bring it all together.
We desire to subscribe to updates on a detail user, which ways we demand to update our show view to connect to the ConversionProgress
Stimulus controller.
<!-- views/users/show.html.erb --> <!-- snip --> <div data-id= "<%= @user.id %>" data-controller= "conversion-progress" fashion= "max-width: 500px; max-height: 500px;" > < % if @ user.convert_video ? % > <div> <p>Nosotros are converting your video. The video is currently <bridge data-conversion-progress-target= "progressText" >0%</span> processed</p> </div> < % else % > <video controls style= "max-width: 100%; max-peak: 100%;" > <source src= "<%= url_for(@user.profile_video) %>" > </video> < % end % > </div> <!-- snip -->
Here we've added a information-id
and a data-controller
to the video's parent video. data-id
is used by the Stimulus controller to know which aqueduct to subscribe to updates from, and the data-controller
is used to connect the Stimulus controller to the DOM.
The other change here is adding logic to display the video as-is when the video has been converted, otherwise, nosotros render text that our Stimulus controller will update equally it receives updates from Action Cablevision.
After making these changes, if you upload a new non-mp4 video to a user and visit the evidence page, y'all'll see the "Nosotros are converting your video" text but the percentage candy will never update. That is because we aren't yet broadcasting the conversion progress to the Activeness Cable channel.
Fortunately, the FFmpeg jewel makes dissemination progress actually uncomplicated. Let's update the transcode
call in our video converter service to broadcast the modify:
movie . transcode ( path , { video_codec: 'libx264' , audio_codec: 'aac' }) { | progress | ActionCable . server . broadcast ( "video_conversion_ #{ @user . id } " , progress ) }
At present nosotros'll see our progress text count up from 0% to 100% when we upload a new video that needs conversion. Depending on the video's properties and your computer'due south power, this might exist a very fast process or information technology could take several minutes. Either fashion, you tin now watch the progress in real fourth dimension!
When the percentage gets to 100% you'll notice one final upshot blocker that we demand to solve. Instead of replacing the video placeholder with the actual video when information technology reaches 100% conversion, the counter just stays there and the user has to refresh the page to see the video. Nosotros can prepare that with a lilliputian more than Action Cable, and a trivial more Stimulus.
First, add some other Action Cable channel with runway g channel ConvertedVideo
and update the subscribed method in the generated _channel.rb file:
class ConvertedVideoChannel < ApplicationCable :: Aqueduct def subscribed stream_from "converted_video_ #{ params [ :id ] } " end finish
Then add together a new Stimulus controller for subscribing to the channel and managing updates with impact app/javascript/controllers/converted_video_controller.js
And add the code to subscribe and handle broadcasts on the ConvertedVideo
channel.
// javascript/controllers/converted_video_controller.js import { Controller } from " stimulus " ; import consumer from " channels/consumer " ; consign default class extends Controller { static targets = [ " videoContainer " ]; initialize () { this . subscription = consumer . subscriptions . create ( { channel : " ConvertedVideoChannel " , id : this . element . dataset . id , }, { connected : this . _connected . bind ( this ), disconnected : this . _disconnected . bind ( this ), received : this . _received . demark ( this ), } ); } _connected () {} _disconnected () {} _received ( data ) { const videoElement = this . videoContainerTarget videoElement . innerHTML = data } }
This Stimulus controller is very like to the final controller. In it, nosotros subscribe to the ConvertedVideoChannel
with the user id. When information is broadcast on the channel, the Stimulus controller looks for a videoContainer
DOM element and replaces the content of that element with the information sent from Action Cablevision. We'll run into what that information looks like next.
Our goal here is to replace the container of the video placeholder element with the actual video once information technology has been candy. Nosotros can exercise this with Activeness Cable past taking advantage of the fact that we tin return a view partial to a string and circulate that string from Activity Cable, making it like shooting fish in a barrel to replace DOM content with HTML broadcast in a bulletin.
To commencement, let'south add together a fractional that renders the video element, and add that to our the view. While we're in the show view, we'll also connect the new Stimulus controller to the DOM.
First run bear upon app/views/users/_profile_video.html.erb
in your concluding and then:
<!-- views/users/_profile_video.html.erb --> <video controls style= "max-width: 100%; max-meridian: 100%;" > <source src= "<%= url_for(user.profile_video) %>" > </video>
<!-- views/users/show.html.erb --> <div information-id= "<%= @user.id %>" information-controller= "conversion-progress converted-video" data-converted-video-target= "videoContainer" mode= "max-width: 500px; max-pinnacle: 500px;" > < % if @ user.convert_video ? % > <div> <p>Nosotros are converting your video. The video is currently <span data-conversion-progress-target= "progressText" >0%</span> candy</p> </div> < % else % > < %= return " profile_video ", user: @ user % > < % finish % > </div>
At present we've moved the video element to a fractional, and updated our show view to render the fractional when the video does not demand to be converted.
Take note of the commencement line in a higher place. We've added the new converted-video
Stimulus controller to the video container's information-controller
aspect. This connects the controller to the DOM and ensures that visitors to the evidence page are subscribed to the ConvertedVideo
channel. Nosotros've also added a data-converted-video-target
attribute to the same <div>
. This target is used by the Stimulus controller to replace the progress text with the video element.
The last stride is to update the VideoConverter
service to circulate a message containing the profile_video
partial on the ConvertedVideo
channel later the video has been converted.
# app/services/video_converter.rb def convert! return unless @user.convert_video? process_video update_needs_conversion render_processed_video end private # Snip def render_processed_video fractional = ApplicationController.render(fractional: "users/profile_video", locals: { user: @user }) ActionCable.server.broadcast("converted_video_#{@user.id}", partial) stop
Here we're calling a new render_processed_video
method from our convert!
method. This method renders a partial to a string and then broadcasts that cord as data, to be picked up and used by our Stimulus controller. Magic.
Permit's see information technology in activeness.
Wrapping up
Thanks for making information technology through this guide! Y'all can find the total source code for this guide on Github.
To recap, today nosotros started with a fresh Rails half-dozen awarding. With the ability of Stimulus, Active Storage, Activeness Cable and, nearly importantly, FFmpeg, we congenital an app that can convert a user-uploaded video file to mp4 when needed. While the file is converting, we communicate the progress to the user and display the converted user to them without asking them to reload the page.
To make the code in this tutorial production-fix, besides cleaning up the code and styling things, yous should spend time validating file uploads from the user, add together a existent background job processor, and add error-handling and resilience to the video conversion service.
You might also add validation for file size to the videos, on both the client and server, and consider more than efficient methods for converting videos to mp4 with more powerful FFmpeg functionality.
Further resources and contact info
If you lot'd similar to see a product version of this projection, have a look at the demo for Vestimonials, the product I'm building when I'm not writing technical articles like this ane.
If yous take questions or feedback on this article, yous tin can notice me on Twitter
Thanks for reading!
Source: https://www.colby.so/posts/building-a-video-converter-with-rails-and-ffmpeg
ارسال یک نظر for "Direct Uploads Error Storing Video.mov. Status: 0 Active Storage"