# frozen_string_literal: true require 'mini_magick' require 'open3' require 'tempfile' class MaterialThumbnailGenerator SIZE = '180x180' class << self def generate! material new(material).generate! end end def initialize material @material = material @ffmpeg_stderr = [] end def generate! return log_result(:no_file) unless @material.file.attached? return log_result(:unsupported_content_type) unless image? || video? @material.file.blob.open do |file| thumbnail = image? ? image_thumbnail(file.path) : video_thumbnail(file.path) return log_result(:generation_failed) unless thumbnail return attach_thumbnail(thumbnail) end rescue ActiveStorage::FileNotFoundError => e log_result(:file_not_found, error: e) rescue MiniMagick::Error => e log_result(:mini_magick_error, error: e) rescue ArgumentError, StandardError => e log_result(:generation_failed, error: e) end private def image? = content_type.start_with?('image/') def video? = content_type.start_with?('video/') def content_type return nil unless @material.file.attached? @material.file.blob.content_type.to_s end def image_thumbnail path image = MiniMagick::Image.open(path) image.resize(SIZE) image.format('jpg') image end def video_thumbnail path [1, 0].each do |seconds| tempfile = Tempfile.new(['material-thumbnail', '.jpg']) tempfile.close ok = extract_video_frame(path, tempfile.path, seconds) next unless ok && File.size?(tempfile.path) return image_thumbnail(tempfile.path) ensure tempfile&.unlink end nil end def extract_video_frame input_path, output_path, seconds _stdout, stderr, status = Open3.capture3('ffmpeg', '-y', '-ss', seconds.to_s, '-i', input_path, '-frames:v', '1', '-f', 'image2', output_path) @ffmpeg_stderr << stderr if stderr.present? status.success? rescue Errno::ENOENT => e @ffmpeg_stderr << "ffmpeg unavailable: #{ e.message }" false end def attach_thumbnail image blob = nil File.open(image.path) do |io| blob = ActiveStorage::Blob.create_and_upload!( io:, filename: 'material-thumbnail.jpg', content_type: 'image/jpeg') end @material.thumbnail.attach(blob) log_result(:attached) rescue StandardError => e blob&.purge_later log_result(:attach_failed, error: e) end def log_result result, error: nil log_payload = { material_id: @material.id, file_blob_id: file_blob_id, content_type:, result:, error_class: error&.class&.name, error_message: error&.message, ffmpeg_stderr: @ffmpeg_stderr.join("\n").presence } if [:attached, :no_file, :unsupported_content_type].include?(result) Rails.logger.info("Material thumbnail generation: #{ log_payload.to_json }") else Rails.logger.warn("Material thumbnail generation: #{ log_payload.to_json }") end result end def file_blob_id return nil unless @material.file.attached? @material.file.blob.id end end