using Rails 7.2.1.1 and Ruby 3.3.5. Gemfile has gem "image_processing", "~> 1.13.0"
.
Basically I have a form that allows the company logo to be uploaded, and needs to accept jpeg, png, gif, webp, and svg versions. SVG files yield an ActiveStorage::InvariableError when the .variant method is used, so they need to be converted. I also discovered in testing that vips chokes on resizing .jfif files via the image_processing gem (which are coded as image/jpeg), so those also need to be detected and converted to jpeg.
I've been trying to implement a job with some basic image processing on images stored on S3 with active_storage. But getting the image data into the image_processing gem is driving me crazy!
I have this model:
class Company < ApplicationRecord # :nodoc:
has_one_attached :logo do |attachable|
attachable.variant :medium, resize_to_fit: [300, nil]
attachable.variant :large, resize_to_fit: [700, nil]
end
after_save :company_logos_job, if: :logos_changed?
private
def company_logos_job
CompanyLogoJob.perform_later(id)
end
def logos_changed?
true # implement later, slightly tricky
end
end
In the job, I tried several ways to get the image blob data into image_processing, but often ended up with i/o errors or file read errors. Working with ChatGPT, I've got it functional like this, but it feels wrong and seems totally inefficient to be creating and deleting two temporary files on disk for each job!
# This job runs after any change to the logo on a company object.
# It will convert the image to a png if it is an svg, and create the variants.
class CompanyLogoJob < ApplicationJob
queue_as :latency_5m
def perform(id)
company = Company.find(id)
if company.logo_light.attached?
convert_svg_to_png(company.logo_light) if company.logo_light.content_type == "image/svg+xml"
company.logo_light :medium
company.logo_light :large
end
end
def convert_svg_to_png(image)
filename = image.filename.to_s
# I've had to create a tempfile to get the image accepted by .source
Tempfile.create(["temp_image", ".svg"]) do |tempfile|
tempfile.write(image.download)
tempfile.rewind
# use VIPS to convert svg to png
png = ImageProcessing::Vips.source(tempfile.path)
.loader(loader: :svg)
.convert("png")
.resize_to_fit(700, nil)
.call
png_file = File.open(png.path, "rb")
image.purge
image.attach(io: StringIO.new(png_file.read), filename: "#{filename}.png", content_type: "image/png")
png_file.close
File.delete(png.path)
tempfile.close
File.delete(tempfile.path)
end
end
end
I've researched the image_processing gem, stack overflow, blog posts, etc for a few hours now, but still haven't found a way to streamline this that works. Is there a way to get blob data into ImageMagick or VIPs without saving local copies of the i/o files? I'm open to using VIPs, ImageMagick, and/or ditching the image_processing gem if it helps, but I feel like there's something really basic that I'm missing here.
Any help / insight, or links to good blogs/tutorial I may have missed are appreciated!
Ok, I've struggled my way though this for almost 2 days now, and finally have something I'm sort-of happy with. There's still room for improvement. Eventually I got it sorted using Vips with some tip offs from this github conversation and this GoRails thread on saving variants in a job.
Model:
class Company < ApplicationRecord # :nodoc:
has_one_attached :logo do |attachable|
attachable.variant :medium, resize_to_fit: [300, nil]
attachable.variant :large, resize_to_fit: [700, nil]
end
after_save :process_logo_if_changed
private
def process_logo_if_changed
ImagePreprocessJob.perform_later(logo.id) if logo.blob&.saved_changes?
end
end
Job:
class ImagePreprocessJob < ApplicationJob
queue_as :latency_5m
def perform(attachment_id)
attachment = ActiveStorage::Attachment.find(attachment_id)
raise "Attachment is not an image" unless attachment&.image?
# svg and jfif will break Vips variants, convert to png
if attachment.content_type == "image/svg+xml" || jfif?(attachment.blob)
convert_to_png(attachment)
attachment = attachment.record.send(attachment.name) # switch to new png attachment
end
raise "Attachment ID: #{attachment.id} is not representable" unless attachment.representable?
# save variants
attachment.representation(resize_to_fit: [300, nil]).processed # medium size
attachment.representation(resize_to_fit: [700, nil]).processed # large size
end
def convert_to_png(attachment)
filename = attachment.filename.to_s.rpartition(".").first # remove existing extension
png = Vips::Image.new_from_buffer(attachment.blob.download, "")
attachment.purge
attachment.record.send(attachment.name).attach(
io: StringIO.new(png.write_to_buffer(".png")),
filename: "#{filename}.png",
content_type: "image/png"
)
end
def jfif?(blob)
file_content = blob.download
return (file_content[6..9] == "JFIF")
end
end
I played with preprocessed: true
in the model as described in the Active Storage Guide, but it would fill the log up with errors as it tries to create variants on invariable svg files before the job runs. So I just moved the processing/saving of variants into the job.
I was not able to solve this using the image_processing gem despite trying several ways. On the whole it was still far more difficult and a more convoluted solution than I expected - I won't mark this as the answer for quite a while as I'd love to see a more elegant and streamlined implementation, and I'm open to suggestions on how this could be improved.