javascriptjqueryhtml5-canvashtml5-videovideo-thumbnails

Generate a thumbnail/snapshot of a video file selected by a file input at a specific time


How do I grab a snapshot of a video file selected via <input type="file"> at a specific time in the video silently in-the-background (i.e. no visible elements, flickering, sound, etc.)?


Solution

  • There are four major steps:

    1. Create <canvas> and <video> elements.
    2. Load the src of the video file generated by URL.createObjectURL into the <video> element and wait for it to load by listening for specific events being fired.
    3. Set the time of the video to the point where you want to take a snapshot and listen for additional events.
    4. Use the canvas to grab the image.

    Step 1 - Create the elements

    This is very easy: just create one <canvas> and one <video> element and append them to <body> (or anywhere really, it doesn't really matter):

    var canvasElem = $( '<canvas class="snapshot-generator"></canvas>' ).appendTo(document.body)[0];
    var $video = $( '<video muted class="snapshot-generator"></video>' ).appendTo(document.body);
    

    Notice that the video element has the attribute muted. Don't put any other attributes like autoplay or controls. Also notice that they both have the class snapshot-generator. This is so we can set the style for both of them so that they are out of the way:

    .snapshot-generator {
      display: block;
      height: 1px;
      left: 0;
      object-fit: contain;
      position: fixed;
      top: 0;
      width: 1px;
      z-index: -1;
    }
    

    Some browsers work with them set to display: none, but other browsers will have serious problems unless they are rendered on the page, so we just make them minuscule so that they are essentially invisible. (Don't move them outside the viewport though, as otherwise you may see some ugly scrollbars on your page.)

    Step 2 - Load the video

    Here's where things start to get tricky. You need to listen to events to know when to continue. Different browsers will fire different events, different times and in different orders, so I'll save you the effort. There are three events that must always fire at least once before the video is ready; they are:

    Set up the event handler for these events and keep track how many have fired. Once all three have fired, you are ready to proceed. Keep in mind that, since some of these events may fire more than once, you only want to handle the first event of each type that is fired, and discard subsequent firings. I used jQuery's .one, which takes care of this.

    var step_2_events_fired = 0;
    $video.one('loadedmetadata loadeddata suspend', function() {
        if (++step_2_events_fired == 3) {
            // Ready for next step
        }
    }).prop('src', insert_source_here);
    

    The source should just be the object URL created via URL.createObjectURL(file), where file is the file object.

    Step 3 - Set the time

    This stage is similar to the previous: set the time and then listen for an event. Inside our if block from the previous code:

    $video.one('seeked', function() {
        // Ready for next step
    }).prop('currentTime', insert_time_here_in_seconds);
    

    Luckily its only one event this time, so it's pretty clear and concise. Finally...

    Step 4 - Grab the snapshot

    This part is just using the <canvas> element to grab a screenshot. Inside our seeked event handler:

    canvas_elem.height = this.videoHeight;
    canvas_elem.width = this.videoWidth;
    canvas_elem.getContext('2d').drawImage(this, 0, 0);
    var snapshot = canvas_elem.toDataURL();
    
    // Remove elements as they are no longer needed
    $video.remove();
    $(canvas_elem).remove();
    

    The canvas needs to match the dimensions of the video (not the <video> element) to get a proper image. Also, we are setting the canvas's internal .height and .width properties, not the canvas height/width CSS style values.

    The value of snapshot is a data URI, which is basically just a string that starts with data:image/jpeg;base64 and then the base64 data.

    Our final JS code should look like:

    var step_2_events_fired = 0;
    $video.one('loadedmetadata loadeddata suspend', function() {
      if (++step_2_events_fired == 3) {
        $video.one('seeked', function() {
          canvas_elem.height = this.videoHeight;
          canvas_elem.width = this.videoWidth;
          canvas_elem.getContext('2d').drawImage(this, 0, 0);
          var snapshot = canvas_elem.toDataURL();
    
          // Delete the elements as they are no longer needed
          $video.remove();
          $(canvas_elem).remove();
        }).prop('currentTime', insert_time_here_in_seconds);
      }
    }).prop('src', insert_source_here);
    

    Celebrate!

    You have your image in base64! Send this to your server, put it as the src of an <img> element, or whatever.

    For example, you can decode it into binary and directly write it to a file (trim the prefix first), which will become a JPEG image file.

    You could also use this to offer previews of videos while they are uploaded. If you are putting it as the src of an <img>, use the full data URI (don't remove the prefix).