# typed: strict class Domain::PostFile::Thumbnail < ReduxApplicationRecord extend T::Sig self.table_name = "domain_post_file_thumbnails" belongs_to :post_file, class_name: "::Domain::PostFile", inverse_of: :thumbnails, touch: true enum :thumb_type, { content_container: 0, size_32_32: 1 } validates :thumb_type, presence: true validates :frame, presence: true validate :validate_content_type! THUMB_ROOT_DIR = T.let(File.join(BlobFile::ROOT_DIR, "thumbnails"), String) THUMB_FILE_PATH_PATTERN = T.let([2, 2, 1].freeze, T::Array[Integer]) SINGLE_FRAME_CONTENT_TYPES = T.let( [%r{image/jpeg}, %r{image/jpg}, %r{image/png}, %r{image/bmp}], T::Array[Regexp], ) MULTI_FRAME_CONTENT_TYPES = T.let( [ %r{image/gif}, %r{image/apng}, %r{image/webp}, %r{video/webm}, %r{video/mp4}, ], T::Array[Regexp], ) THUMBABLE_CONTENT_TYPES = T.let( SINGLE_FRAME_CONTENT_TYPES + MULTI_FRAME_CONTENT_TYPES, T::Array[Regexp], ) THUMB_TYPE_TO_OPTIONS = T.let( { content_container: LoadedMedia::ThumbnailOptions.new( width: 768, height: 2048, quality: 95, size: :down, interlace: true, for_frames: [0], ), size_32_32: LoadedMedia::ThumbnailOptions.new( width: 32, height: 32, quality: 95, size: :force, interlace: false, for_frames: [0.0, 0.1, 0.5, 0.9, 1.0], ), }, T::Hash[Symbol, LoadedMedia::ThumbnailOptions], ) sig { returns(T.nilable(String)) } def absolute_file_path return nil unless (thumb_type = self.thumb_type) return nil unless (post_file = self.post_file) return nil unless (sha256 = post_file.sha256) sha256_hex = HexUtil.bin2hex(sha256) self.class.absolute_file_path(sha256_hex, thumb_type, T.must(self.frame)) end sig do params(sha256_hex: String, thumb_type: String, frame: Integer).returns( T.nilable(String), ) end def self.absolute_file_path(sha256_hex, thumb_type, frame) [ THUMB_ROOT_DIR, thumb_type.to_s, *BlobFile.path_segments(THUMB_FILE_PATH_PATTERN, sha256_hex), ].join("/") + "-#{frame.to_i.to_s.rjust(2, "0")}.jpg" end sig { params(post_file: Domain::PostFile).returns(T::Boolean) } def self.can_thumbnail_post_file?(post_file) return false unless content_type = post_file.content_type can_thumbnail_content_type?(content_type) end sig { params(content_type: String).returns(T::Boolean) } def self.can_thumbnail_content_type?(content_type) THUMBABLE_CONTENT_TYPES.any? { |type| content_type.match?(type) } end sig do params(post_file: Domain::PostFile).returns( T::Array[Domain::PostFile::Thumbnail], ) end def self.create_for_post_file!(post_file) logger.tagged("thumbnail", make_arg_tag(post_file)) do return [] unless content_type = post_file.content_type return [] unless can_thumbnail_content_type?(content_type) blob_file_path = post_file.blob&.absolute_file_path return [] unless blob_file_path media = LoadedMedia.from_file(content_type, blob_file_path) return [] unless media num_frames = media.num_frames logger.info(format_tags(make_tag("num_frames", num_frames))) return [] if num_frames.zero? existing_thumb_types = T.cast(post_file.thumbnails.map(&:thumb_type).uniq, T::Array[String]) logger.info( format_tags(make_tag("existing_thumb_types", existing_thumb_types)), ) FileUtils.mkdir_p(BlobFile::TMP_DIR) thumbnails = [] THUMB_TYPE_TO_OPTIONS.each do |thumb_type, options| thumb_type = thumb_type.to_s logger.tagged(make_tag("thumb_type", thumb_type)) do if existing_thumb_types.include?(thumb_type) logger.info(format_tags("thumbnail type already exists")) next end # get the number of frames in the post file frames_to_thumbnail = options .for_frames .map { |frame_fraction| (frame_fraction * (num_frames - 1)).to_i } .uniq .sort frames_to_thumbnail.each do |frame| logger.tagged(make_tag("frame", frame)) do logger.info(format_tags("creating thumbnail")) thumbnail = post_file.thumbnails.build(thumb_type:, frame:) unless thumb_file_path = thumbnail.absolute_file_path logger.info(format_tags("unable to compute thumbnail path")) next end if File.exist?(thumb_file_path) logger.info(format_tags("thumbnail file already exists")) else media.write_frame_thumbnail(frame, thumb_file_path, options) end thumbnail.save! thumbnails << thumbnail end end end end thumbnails end end private sig { void } def validate_content_type! return unless (post_file = self.post_file) content_type = post_file.content_type if content_type.nil? errors.add(:post_file, "must have a content type") return end unless self.class.can_thumbnail_content_type?(content_type) errors.add(:post_file, "must be a thumbnailable content type") end end end