ruby-on-railsruby-on-rails-7ruby-on-rails-7.2

How to use active_storage blob with image_processing without temporary files on disk?


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!


Solution

  • 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.