Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 680
bbalan Avatar asked Feb 08 '20 20:02

bbalan


2 Answers

Rails Built in Solution

According to ActiveStorage Overview Guild there is already 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

like image 110
equivalent8 Avatar answered Nov 20 '22 06:11

equivalent8


Answering own question: my original solution was close, but required ImageMagick to be installed (it wasn't, and the error messages did not point that out). This was my final code:

class Image < ApplicationRecord
  attr_accessor :skip_set_dimensions
  after_commit ({unless: :skip_set_dimensions}) { |image| set_dimensions image }

  has_one_attached :file

  def set_dimensions(image)
    if (Image.exists?(image.id))
      if (image.file.attached?)
        meta = ActiveStorage::Analyzer::ImageAnalyzer.new(image.file).metadata

        image.width = meta[:width]
        image.height = meta[:height]
      else
        image.width = 0
        image.height = 0
      end

      image.skip_set_dimensions = true
      image.save!
    end
  end
end

I also used this technique to skip the callback on save!, preventing an infinite loop.

like image 4
bbalan Avatar answered Nov 20 '22 06:11

bbalan