Search code examples
ruby-on-railsrails-activestorage

ActiveStorage - get image dimensions after upload


I am using Rails + ActiveStorage to upload image files, and would like to save the width and height in the database after upload. However, I'm having trouble finding any examples of this anywhere.

This is what I've cobbled together from various API docs, but just end up with this error: private method 'open' called for #<String:0x00007f9480610118>. Replacing blob with image.file causes rails to log "Skipping image analysis because ImageMagick doesn't support the file" (https://github.com/rails/rails/blob/master/activestorage/lib/active_storage/analyzer/image_analyzer.rb#L39).

Code:

class Image < ApplicationRecord
  after_commit { |image| set_dimensions image }

  has_one_attached :file

  def set_dimensions(image)
    if (image.file.attached?)
      blob = image.file.download

      # error: private method `open' called for #<String:0x00007f9480610118>
      meta = ActiveStorage::Analyzer::ImageAnalyzer.new(blob).metadata
    end
  end
end

This approach is also problematic because after_commit is also called on destroy.

TLDR: Is there a "proper" way of getting image metadata immediately after upload?


Solution

  • Rails Built in Solution

    According to the ActiveStorage Overview Guide there is already an existing solution image.file.analyze and image.file.analyze_later (docs ) which uses ActiveStorage::Analyzer::ImageAnalyzer

    According to #analyze docs :

    New blobs are automatically and asynchronously analyzed via analyze_later when they're attached for the first time.

    That means you can access your image dimensions with

    image.file.metadata
    #=> {"identified"=>true, "width"=>2448, "height"=>3264, "analyzed"=>true}
    
    image.file.metadata['width']
    image.file.metadata['height']
    

    So your model can look like:

    class Image < ApplicationRecord
      has_one_attached :file
    
      def height
        file.metadata['height']
      end
    
      def width
        file.metadata['width']
      end
    end
    

    For 90% of regular cases you are good with this

    BUT: the problem is this is "asynchronously analyzed" (#analyze_later) meaning you will not have the metadata stored right after upload

    image.save!
    image.file.metadata
    #=> {"identified"=>true}
    image.file.analyzed?
    # => nil
    
    # .... after ActiveJob for analyze_later finish
    image.reload
    image.file.analyzed?
    # => true
    #=> {"identified"=>true, "width"=>2448, "height"=>3264, "analyzed"=>true}
    

    That means if you need to access width/height in real time (e.g. API response of dimensions of freshly uploaded file) you may need to do

    class Image < ApplicationRecord
      has_one_attached :file
      after_commit :save_dimensions_now
    
      def height
        file.metadata['height']
      end
    
      def width
        file.metadata['width']
      end
    
      private
      def save_dimensions_now
        file.analyze if file.attached?
      end
    end
    

    Note: there is a good reason why this is done async in a Job. Responses of your request will be slightly slower due to this extra code execution needs to happen. So you need to have a good reason to "save dimensions now"

    Mirror of this solution can be found at How to store Image Width Height in Rails ActiveStorage



    DIY solution

    recommendation: don't do it, rely on existing Vanilla Rails solution

    Models that need to update attachment

    Bogdan Balan's solution will work. Here is a rewrite of same solution without the skip_set_dimensions attr_accessor

    class Image < ApplicationRecord
      after_commit :set_dimensions
    
      has_one_attached :file
    
      private
    
      def set_dimensions
        if (file.attached?)
          meta = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata
          height = meta[:height]
          width  = meta[:width]
        else
          height = 0
          width  = 0
        end
    
        update_columns(width: width, height: height) # this will save to DB without Rails callbacks
      end
    end
    

    update_columns docs

    Models that don't need to update attachment

    Chances are that you may be creating model in which you want to store the file attachment and never update it again. (So if you ever need to update the attachment you just create new model record and delete the old one)

    In that case the code is even slicker:

    class Image < ApplicationRecord
      after_commit :set_dimensions, on: :create
    
      has_one_attached :file
    
      private
    
      def set_dimensions
        meta = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata
        self.height = meta[:height] || 0
        self.width  = meta[:width] || 0
        save!
      end
    end
    

    Chances are you want to validate if the attachment is present before saving. You can use active_storage_validations gem

    class Image < ApplicationRecord
      after_commit :set_dimensions, on: :create
    
      has_one_attached :file
    
      # validations by active_storage_validations
      validates :file, attached: true,
        size: { less_than: 12.megabytes , message: 'image too large' },
        content_type: { in: ['image/png', 'image/jpg', 'image/jpeg'], message: 'needs to be an PNG or JPEG image' }
    
      private
    
      def set_dimensions
        meta = ActiveStorage::Analyzer::ImageAnalyzer.new(file).metadata
        self.height = meta[:height] || 0
        self.width  = meta[:width] || 0
        save!
      end
    end
    

    test

    require 'rails_helper'
    RSpec.describe Image, type: :model do
      let(:image) { build :image, file: image_file }
    
      context 'when trying to upload jpg' do
        let(:image_file) { FilesTestHelper.jpg } # https://blog.eq8.eu/til/factory-bot-trait-for-active-storange-has_attached.html
    
        it do
          expect { image.save }.to change { image.height }.from(nil).to(35)
        end
    
        it do
          expect { image.save }.to change { image.width }.from(nil).to(37)
        end
    
        it 'on update it should not cause infinitte loop' do
          image.save! # creates
          image.rotation = 90 # whatever change, some random property on Image model
          image.save! # updates
          # no stack ofverflow happens => good
        end
      end
    
      context 'when trying to upload pdf' do
        let(:image_file) { FilesTestHelper.pdf } # https://blog.eq8.eu/til/factory-bot-trait-for-active-storange-has_attached.html
    
        it do
          expect { image.save }.not_to change { image.height }
        end
      end
    end
    

    How FilesTestHelper.jpg work is explained in article attaching Active Storange to Factory Bot