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?
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
recommendation: don't do it, rely on existing Vanilla Rails solution
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
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
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With