Submit Client-side Compressed Images in a Form

·

5 min read

Hobby projects are great because they can expose you to problems you've never encountered before. While working on Scavenger Hunt Party! I was noticing some issues in the real-world that were not present in my development environment. The biggest one was related to latency and inconsistent Internet speeds while users are on mobile networks. Photos taken on a phone can be really big and uploading those to the server on a poor connection was causing issues. Here's how I compressed the images client-side to reduce data usage.

Scavenger Hunt Party! is a Ruby on Rails based web app that manages photo scavenger hunts for participants. A user can create a scavenger hunt from a premade list, invite others to join, create teams that share a list, and a results page for users to view everyone's submissions.

From the hunt page users can click on an item and upload a photo or video. Once the file is attached it submits the form and updates the page. The problem I was seeing was the upload times on images was high when uploading from the camera of a phone. After a little searching I came across compress.js which is a JavaScript library for compressing images. With that library I had all the tools needed to handle client-side compression.

The path to getting it going was to compress the images that are attached to the file input, then change the file on the input to the compressed image, and submit the form with only the compressed image.

Compress images as they are attached

This project is a very vanilla Rails app so the stack is Ruby on Rails on the backend with Hotwire handling JavaScript and Tailwind for the CSS. I already had a Stimulus file handling the form submission when a file was added so that was a good place to start.

<%# _submission.html.erb %>

<%= form_with model: submission, html: { id: object_id, data: { 'submission-target': 'form' } } do |form| %>
  <%= form.label :photo, submission.item.name, for: "submission_#{object_id}", class: 'cursor-pointer' %>
  <%= form.file_field :photo, 
    id: "submission_#{object_id}", 
    class: 'hidden', 
    accept: 'image/*,video/*', 
    data: { 
      action: 'change->submission#submitForm', 
      'submission-target': 'field' 
    } %>
<% end %>

The _submission.html.erb partial has a form for the submission with a hidden file_field to allow us to style the page better. When you click the label it opens the field and once a file is attached the Stimulus submission_controller.js handles the change.

// submission_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [ "form", "icon" ]

  submitForm(event) {
    this.iconTarget.innerHTML = '<ion-icon name="cloud-upload-outline" class="animate__animated animate__bounce animate__infinite"></ion-icon>'
    if (this.formTarget.requestSubmit){
      this.formTarget.requestSubmit()
    } else {
      this.formTarget.submit()
    }
    event.target.disabled = true
  }
}

This was the state of the Stimulus controller without the image compression. Once an image is attached we change the icon to let the user know that something is happening then we submit the form and disable the field from being editable. When the form submits and the response is successful the page updates to show the change.

Using the compress.js library we can compress the images just before submitting the form. We need to wrap the compression in a Promise to make sure compression completes before submitting. If we don't use the promise the compression will start and execution will continue on to submitting the form without changing files and we're still stuck with the original large file.

// submission_controller.js

import { Controller } from "@hotwired/stimulus"
import Compress from "compress.js"

export default class extends Controller {
  static targets = [ "field", "form", "icon" ]

  submitForm(event) {
    this.iconTarget.innerHTML = '<ion-icon name="cloud-upload-outline" class="animate__animated animate__bounce animate__infinite"></ion-icon>'
    this.compressImage().then(() => {
      if (this.formTarget.requestSubmit){
        this.formTarget.requestSubmit()
      } else {
        this.formTarget.submit()
      }
      event.target.disabled = true
    }).catch((e) => { console.log(e) })
  }

  compressImage() {
    const fieldTarget = this.fieldTarget
    const fileListItems = this.fileListItems.bind(this)
    return new Promise(function(resolve, reject) {
      try {
        const compress = new Compress()
        const files = [...fieldTarget.files]
        if (!files.every((file) => file && file['type'].split('/')[0] === 'image')) {
          return resolve()
        }
        compress.compress(files, {}).then((results) => {
          const img = results[0]
          const base64str = img.data
          const imgExt = img.ext
          const file = new File([Compress.convertBase64ToFile(base64str, imgExt)], img.name)
          fieldTarget.files = fileListItems([file])
          resolve()
        })
      } catch (e) {
        reject(e)
      }
    })
  }

  fileListItems(files) {
    var b = new DataTransfer()
    for (var i = 0, len = files.length; i < len; i++) b.items.add(files[i])
    return b.files
  }
}

The compressImage() function loops through the files on the input, even though it's set to only take one file we might as well make sure to handle multiple in case that's something we deal with in the future, and makes sure that we are actually dealing with images. The input supports video files as well and if a video is being attached we just have to deal with it.

Then using compress.js we compress the files and attach them to a new DataTransfer() object created by the fileListItems(files) function. Attaching files to an HTML input needs to be in a DataTransfer object and doesn't support something like an array of Files for example. With the files attached to the file field we are safe to submit the form.

Results

Using an image from Unsplash that weighs in at 16.2 MB using the methods above the file is compressed to 1.61 MB. Definitely an improvement and should help with people using phones out in the wild. The compress.js library has some levers I could play with to get that number down but I don't want to ruin image quality too much. After all, this is a photo scavenger hunt so quality images is important.

Overall I'm happy with the result and hope it makes the experience for users better while running around taking photos out in the world.