0

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!

7
  • have you tried just using the variant method? company.logo.variant(resize_to_fit: [700,nil], loader: :svg, convert: :png).processed and then doing whatever you might need to do using the ActiveStorage::Preview e.g. downloading. Commented Nov 15, 2024 at 21:25
  • Yes. That gives ActiveStorage::InvariableError, even when using the loader: :svg tag. Commented Nov 15, 2024 at 22:55
  • Given that your conversion currently works, seems you can potentially bypass that error by adding the MIME type as variable?. See this post:stackoverflow.com/questions/60686249/… Commented Nov 16, 2024 at 1:01
  • Do you mean monkey patch active storage to list svg as variable? Or changing the content type on the svg blob to fool it? Or? I considered that approach, but was disuaded by: (a) stackoverflow.com/questions/53629110/… which noted security concerns with serving internet-sourced svg's as binary, and (b) the fact that there are still issues with jfif files, which have a variable content type (image/jpeg), so I need a job to convert them anyways. Very interested in leaner solutions though! Commented Nov 16, 2024 at 16:13
  • 1
    A little more work and I was able to get jfif's fixed too. Issue was the ubuntu vips package was version 8.13, but the jfif issue was fixed in vips 8.15.1 (github.com/libvips/libvips/issues/3775), which also happens to be the version provided on Heroku :-). Now I can do away with the job entirely and just use preprocessed: true ! Huzzah!! Thank you! Commented Nov 16, 2024 at 23:27

2 Answers 2

2

The ultimate solution, as guided by engineersmnky in the comments, was to simply add config.active_storage.variable_content_types << 'image/svg+xml' in my application.rb.

With that, the model became:

class Company < ApplicationRecord # :nodoc:
  has_one_attached :logo do |attachable|
    attachable.variant :medium, resize_to_fit: [300, nil], preprocessed: true
    attachable.variant :large, resize_to_fit: [700, nil], preprocessed: true
  end
end

And the job was removed entirely. Much better.

However, I will mark my other (first) answer as accepted, because it is the literal answer to the headline question of how to get blob data into Vips without temporary files. As usual, part of the issue was not asking the right question!

Sign up to request clarification or add additional context in comments.

Comments

1

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.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.