1
0

vips_lazy_thumbnail.rb 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. # frozen_string_literal: true
  2. module Paperclip
  3. class LazyThumbnail < Paperclip::Processor
  4. GIF_MAX_FPS = 60
  5. GIF_MAX_FRAMES = 3000
  6. GIF_PALETTE_COLORS = 32
  7. ALLOWED_FIELDS = %w(
  8. icc-profile-data
  9. ).freeze
  10. class PixelGeometryParser
  11. def self.parse(current_geometry, pixels)
  12. width = Math.sqrt(pixels * (current_geometry.width.to_f / current_geometry.height)).round.to_i
  13. height = Math.sqrt(pixels * (current_geometry.height.to_f / current_geometry.width)).round.to_i
  14. Paperclip::Geometry.new(width, height)
  15. end
  16. end
  17. def initialize(file, options = {}, attachment = nil)
  18. super
  19. @crop = options[:geometry].to_s[-1, 1] == '#'
  20. @current_geometry = options.fetch(:file_geometry_parser, Geometry).from_file(@file)
  21. @target_geometry = options[:pixels] ? PixelGeometryParser.parse(@current_geometry, options[:pixels]) : options.fetch(:string_geometry_parser, Geometry).parse(options[:geometry].to_s)
  22. @format = options[:format]
  23. @current_format = File.extname(@file.path)
  24. @basename = File.basename(@file.path, @current_format)
  25. correct_current_format!
  26. end
  27. def make
  28. return File.open(@file.path) unless needs_convert?
  29. dst = TempfileFactory.new.generate([@basename, @format ? ".#{@format}" : @current_format].join)
  30. if preserve_animation?
  31. if @target_geometry.nil? || (@current_geometry.width <= @target_geometry.width && @current_geometry.height <= @target_geometry.height)
  32. target_width = 'iw'
  33. target_height = 'ih'
  34. else
  35. scale = [@target_geometry.width.to_f / @current_geometry.width, @target_geometry.height.to_f / @current_geometry.height].min
  36. target_width = (@current_geometry.width * scale).round
  37. target_height = (@current_geometry.height * scale).round
  38. end
  39. # The only situation where we use crop on GIFs is cropping them to a square
  40. # aspect ratio, such as for avatars, so this is the only special case we
  41. # implement. If cropping ever becomes necessary for other situations, this will
  42. # need to be expanded.
  43. crop_width = crop_height = [target_width, target_height].min if @target_geometry&.square?
  44. filter = begin
  45. if @crop
  46. "scale=#{target_width}:#{target_height}:force_original_aspect_ratio=increase,crop=#{crop_width}:#{crop_height}"
  47. else
  48. "scale=#{target_width}:#{target_height}:force_original_aspect_ratio=decrease"
  49. end
  50. end
  51. command = Terrapin::CommandLine.new(Rails.configuration.x.ffmpeg_binary, '-nostdin -i :source -map_metadata -1 -fpsmax :max_fps -frames:v :max_frames -filter_complex :filter -y :destination', logger: Paperclip.logger)
  52. command.run({ source: @file.path, filter: "#{filter},split[a][b];[a]palettegen=max_colors=#{GIF_PALETTE_COLORS}[p];[b][p]paletteuse=dither=bayer", max_fps: GIF_MAX_FPS, max_frames: GIF_MAX_FRAMES, destination: dst.path })
  53. else
  54. transformed_image.write_to_file(dst.path, **save_options)
  55. end
  56. dst
  57. rescue Vips::Error, Terrapin::ExitStatusError => e
  58. raise Paperclip::Error, "Error while optimizing #{@basename}: #{e}"
  59. rescue Terrapin::CommandNotFoundError
  60. raise Paperclip::Errors::CommandNotFoundError, 'Could not run the `ffmpeg` command. Please install ffmpeg.'
  61. end
  62. private
  63. def correct_current_format!
  64. # If the attachment was uploaded through a base64 payload, the tempfile
  65. # will not have a file extension. It could also have the wrong file extension,
  66. # depending on what the uploaded file was named. We correct for this in the final
  67. # file name, which is however not yet physically in place on the temp file, so we
  68. # need to use it here. Mind that this only reliably works if this processor is
  69. # the first in line and we're working with the original, unmodified file.
  70. @current_format = File.extname(attachment.instance_read(:file_name))
  71. end
  72. def transformed_image
  73. # libvips has some optimizations for resizing an image on load. If we don't need to
  74. # resize the image, we have to load it a different way.
  75. if @target_geometry.nil?
  76. Vips::Image.new_from_file(preserve_animation? ? "#{@file.path}[n=-1]" : @file.path, access: :sequential).copy.mutate do |mutable|
  77. (mutable.get_fields - ALLOWED_FIELDS).each do |field|
  78. mutable.remove!(field)
  79. end
  80. end
  81. else
  82. Vips::Image.thumbnail(@file.path, @target_geometry.width, height: @target_geometry.height, **thumbnail_options).mutate do |mutable|
  83. (mutable.get_fields - ALLOWED_FIELDS).each do |field|
  84. mutable.remove!(field)
  85. end
  86. end
  87. end
  88. end
  89. def thumbnail_options
  90. @crop ? { crop: :centre } : { size: :down }
  91. end
  92. def save_options
  93. case @format
  94. when 'jpg'
  95. { Q: 90, interlace: true }
  96. else
  97. {}
  98. end
  99. end
  100. def preserve_animation?
  101. @format == 'gif' || (@format.blank? && @current_format == '.gif')
  102. end
  103. def needs_convert?
  104. needs_different_geometry? || needs_different_format? || needs_metadata_stripping?
  105. end
  106. def needs_different_geometry?
  107. (options[:geometry] && @current_geometry.width != @target_geometry.width && @current_geometry.height != @target_geometry.height) ||
  108. (options[:pixels] && @current_geometry.width * @current_geometry.height > options[:pixels])
  109. end
  110. def needs_different_format?
  111. @format.present? && @current_format != ".#{@format}"
  112. end
  113. def needs_metadata_stripping?
  114. @attachment.instance.respond_to?(:local?) && @attachment.instance.local?
  115. end
  116. end
  117. end