factor out resizing logic

This commit is contained in:
Dylan Knutson
2025-03-09 22:40:37 +00:00
parent a209c64149
commit 55f806c5b4
21 changed files with 1767 additions and 65 deletions

View File

@@ -126,6 +126,7 @@ gem "ruby-prof"
gem "ruby-prof-speedscope"
gem "ruby-vips"
gem "dhash-vips"
gem "ffmpeg", git: "https://github.com/instructure/ruby-ffmpeg", tag: "v6.1.2"
gem "table_print"
gem "zstd-ruby"
gem "rouge"

View File

@@ -5,6 +5,14 @@ GIT
specs:
dtext_rb (1.11.0)
GIT
remote: https://github.com/instructure/ruby-ffmpeg
revision: a3404b8fa275e2eb9549f074906461b0266a70ea
tag: v6.1.2
specs:
ffmpeg (6.1.2)
multi_json (~> 1.8)
GIT
remote: https://github.com/railsjazz/rails_live_reload
revision: dcd3b73904594e2c5134c2f6e05954f3937a8d29
@@ -269,6 +277,7 @@ GEM
mini_mime (1.1.5)
minitest (5.25.4)
msgpack (1.7.5)
multi_json (1.15.0)
neighbor (0.5.1)
activerecord (>= 7)
net-imap (0.5.4)
@@ -582,6 +591,7 @@ DEPENDENCIES
dtext_rb!
factory_bot_rails
faiss
ffmpeg!
good_job (~> 4.6)
htmlbeautifier
http (~> 5.2)

View File

@@ -10,6 +10,8 @@ module Domain::StaticFileJobHelper
sig { params(post_file: Domain::PostFile).void }
def download_post_file(post_file)
should_enqueue_thumbnail_job = T.let(false, T::Boolean)
if post_file.state_terminal_error?
logger.error(format_tags("terminal error state, skipping"))
return
@@ -53,6 +55,7 @@ module Domain::StaticFileJobHelper
post_file.last_status_code = response.status_code
logger.tagged(make_arg_tag(response.log_entry)) do
if response.status_code == 200
should_enqueue_thumbnail_job = true
post_file.state_ok!
post_file.retry_count = 0
logger.info(format_tags("downloaded file"))
@@ -82,5 +85,8 @@ module Domain::StaticFileJobHelper
end
ensure
post_file.save! if post_file
if should_enqueue_thumbnail_job
defer_job(Domain::PostFileThumbnailJob, { post_file: })
end
end
end

57
app/lib/loaded_media.rb Normal file
View File

@@ -0,0 +1,57 @@
# typed: strict
# frozen_string_literal: true
class LoadedMedia
extend T::Sig
extend T::Helpers
abstract!
sig do
params(content_type: String, media_path: String).returns(
T.nilable(LoadedMedia),
)
end
def self.from_file(content_type, media_path)
case content_type
when %r{image/gif}
LoadedMedia::Gif.new(media_path)
when %r{image/jpeg}, %r{image/jpg}, %r{image/png}, %r{image/bmp}
LoadedMedia::StaticImage.new(media_path)
when %r{video/webm}
LoadedMedia::Webm.new(media_path)
else
return nil
end
end
sig { abstract.returns(Integer) }
def num_frames
end
sig do
abstract
.params(frame: Integer, path: String, options: ThumbnailOptions)
.void
end
def write_frame_thumbnail(frame, path, options)
end
protected
sig(:final) do
params(image: Vips::Image, path: String, options: ThumbnailOptions).void
end
def write_image_thumbnail(image, path, options)
FileUtils.mkdir_p(File.dirname(path))
tmp_path =
File.join(BlobFile::TMP_DIR, "thumbnail-#{SecureRandom.uuid}.jpg")
image.thumbnail_image(
options.width,
height: options.height,
size: options.size,
).jpegsave(tmp_path, interlace: options.interlace, Q: options.quality)
FileUtils.mv(tmp_path, path)
ensure
File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
end
end

View File

@@ -0,0 +1,29 @@
# typed: strict
# frozen_string_literal: true
class LoadedMedia::Gif < LoadedMedia
sig { params(media_path: String).void }
def initialize(media_path)
@vips_image = T.let(Vips::Image.gifload(media_path, n: -1), Vips::Image)
@num_frames = T.let(@vips_image.get("n-pages"), Integer)
@width = T.let(@vips_image.width, Integer)
@height = T.let(@vips_image.height / @num_frames, Integer)
end
sig { override.returns(Integer) }
def num_frames
@num_frames
end
sig do
override
.params(frame: Integer, path: String, options: ThumbnailOptions)
.void
end
def write_frame_thumbnail(frame, path, options)
# crop out the frame we're interested in
# then save it to the path
image = @vips_image.crop(0, frame * @height, @width, @height)
write_image_thumbnail(image, path, options)
end
end

View File

@@ -0,0 +1,23 @@
# typed: strict
# frozen_string_literal: true
class LoadedMedia::StaticImage < LoadedMedia
sig { params(media_path: String).void }
def initialize(media_path)
@vips_image = T.let(Vips::Image.new_from_file(media_path), Vips::Image)
end
sig { override.returns(Integer) }
def num_frames
1
end
sig do
override
.params(frame: Integer, path: String, options: ThumbnailOptions)
.void
end
def write_frame_thumbnail(frame, path, options)
write_image_thumbnail(@vips_image, path, options)
end
end

View File

@@ -0,0 +1,14 @@
# typed: strict
# frozen_string_literal: true
class LoadedMedia::ThumbnailOptions < T::ImmutableStruct
extend T::Sig
const :width, Integer
const :height, T.nilable(Integer)
# 0..100
const :quality, T.nilable(Integer)
# :both, :up, :down, :force
const :size, Symbol
const :interlace, T::Boolean
const :for_frames, T::Array[Numeric]
end

View File

@@ -0,0 +1,47 @@
# typed: strict
# frozen_string_literal: true
FFMPEG.logger = Logger.new(nil)
class LoadedMedia::Webm < LoadedMedia
include HasColorLogger
sig { params(media_path: String).void }
def initialize(media_path)
@media = T.let(FFMPEG::Media.new(media_path), FFMPEG::Media)
duration = @media.duration
frame_rate = @media.frame_rate
@num_frames = T.let((frame_rate * duration).to_i, Integer)
@duration = T.let(duration, Float)
raise("no frames found in webm") if @num_frames.zero?
end
sig { override.returns(Integer) }
def num_frames
@num_frames
end
sig do
override
.params(frame: Integer, path: String, options: ThumbnailOptions)
.void
end
def write_frame_thumbnail(frame, path, options)
frame_time = (frame.to_f * @duration) / @num_frames
frame_time = frame_time.clamp(0.0..(@duration * 0.99))
logger.info(
format_tags(make_tag("frame", frame), make_tag("frame_time", frame_time)),
)
tmp_path =
File.join(BlobFile::TMP_DIR, "webm-#{frame}-#{SecureRandom.uuid}.png")
FileUtils.mkdir_p(File.dirname(tmp_path))
@media.screenshot(tmp_path, { seek_time: frame_time })
raise("screenshot @ #{frame} failed") unless File.exist?(tmp_path)
image = Vips::Image.new_from_file(tmp_path)
write_image_thumbnail(image, path, options)
ensure
FileUtils.rm_rf(tmp_path) if tmp_path && File.exist?(tmp_path)
end
end

View File

@@ -12,8 +12,7 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
validates :frame, presence: true
validate :validate_content_type!
THUMB_ROOT_DIR =
T.let(File.join(BlobFile::ROOT_DIR, "domain_post_file_thumbnails"), String)
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])
@@ -41,23 +40,11 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
T::Array[Regexp],
)
class ThumbnailOptions < T::ImmutableStruct
extend T::Sig
const :width, Integer
const :height, T.nilable(Integer)
# 0..100
const :quality, T.nilable(Integer)
# :both, :up, :down, :force
const :size, Symbol
const :interlace, T::Boolean
const :for_frames, T::Array[Numeric]
end
THUMB_TYPE_TO_OPTIONS =
T.let(
{
content_container:
ThumbnailOptions.new(
LoadedMedia::ThumbnailOptions.new(
width: 768,
height: 2048,
quality: 95,
@@ -66,7 +53,7 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
for_frames: [0],
),
size_32_32:
ThumbnailOptions.new(
LoadedMedia::ThumbnailOptions.new(
width: 32,
height: 32,
quality: 95,
@@ -75,7 +62,7 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
for_frames: [0.0, 0.1, 0.5, 0.9, 1.0],
),
},
T::Hash[Symbol, ThumbnailOptions],
T::Hash[Symbol, LoadedMedia::ThumbnailOptions],
)
sig { returns(T.nilable(String)) }
@@ -86,8 +73,9 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
sha256_hex = HexUtil.bin2hex(sha256)
[
THUMB_ROOT_DIR,
thumb_type.to_s,
*BlobFile.path_segments(THUMB_FILE_PATH_PATTERN, sha256_hex),
].join("/") + ".jpg"
].join("/") + "-#{frame.to_i.to_s.rjust(2, "0")}.jpg"
end
sig { params(post_file: Domain::PostFile).returns(T::Boolean) }
@@ -113,8 +101,10 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
blob_file_path = post_file.blob&.absolute_file_path
return [] unless blob_file_path
vips_image = Vips::Image.new_from_file(blob_file_path)
num_frames = frames_for_image(content_type, vips_image)
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?
@@ -131,14 +121,14 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
logger.info(format_tags("creating thumbnail"))
# get the number of frames in the post file
frame_numbers =
frames_to_thumbnail =
options
.for_frames
.map { |frame_fraction| (frame_fraction * (num_frames - 1)).to_i }
.uniq
.sort
frame_numbers.each do |frame|
frames_to_thumbnail.each do |frame|
logger.tagged(make_tag("frame", frame)) do
thumbnail = post_file.thumbnails.build(thumb_type:, frame:)
unless thumb_file_path = thumbnail.absolute_file_path
@@ -149,24 +139,7 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
if File.exist?(thumb_file_path)
logger.info(format_tags("thumbnail file already exists"))
else
FileUtils.mkdir_p(File.dirname(thumb_file_path))
tmp_file_path =
File.join(
BlobFile::TMP_DIR,
"thumb-#{thumb_type}-#{SecureRandom.uuid}",
)
logger.info(format_tags("writing thumbnail file"))
# TODO - check that this works for gifs, mp4, etc
vips_image.thumbnail_image(
options.width,
height: options.height,
size: options.size,
).jpegsave(
tmp_file_path,
interlace: options.interlace,
Q: options.quality,
)
FileUtils.mv(tmp_file_path, thumb_file_path)
media.write_frame_thumbnail(frame, thumb_file_path, options)
end
thumbnail.save!
@@ -179,18 +152,6 @@ class Domain::PostFile::Thumbnail < ReduxApplicationRecord
end
end
sig { params(content_type: String, vips_image: Vips::Image).returns(Integer) }
def self.frames_for_image(content_type, vips_image)
case content_type
when %r{image/gif}
vips_image.get("n-pages")
when %r{video/webm}, %r{video/mp4}
vips_image.get("n-frames")
else
1
end
end
private
sig { void }

View File

@@ -17,7 +17,9 @@ psql-dump-domain-fa-favs:
@psql -P pager=off -c 'select user_id, post_id, 1 from domain_fa_favs limit 10000000;' -d redux_prod -h 10.166.33.171 -U scraper_redux -t -A -F ' '
test:
bundle exec srb tc && RAILS_ENV=test bin/rake parallel:spec
bundle exec srb tc
rm -rf tmp/blob_files_test/thumbnails
RAILS_ENV=test bin/rake parallel:spec
tc *args:
bundle exec srb tc {{args}}

16
sorbet/rbi/dsl/loaded_media/webm.rbi generated Normal file
View File

@@ -0,0 +1,16 @@
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for dynamic methods in `LoadedMedia::Webm`.
# Please instead update this file by running `bin/tapioca dsl LoadedMedia::Webm`.
class LoadedMedia::Webm
sig { returns(ColorLogger) }
def logger; end
class << self
sig { returns(ColorLogger) }
def logger; end
end
end

File diff suppressed because it is too large Load Diff

268
sorbet/rbi/gems/multi_json@1.15.0.rbi generated Normal file
View File

@@ -0,0 +1,268 @@
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for types exported from the `multi_json` gem.
# Please instead update this file by running `bin/tapioca gem multi_json`.
# source://multi_json//lib/multi_json/options.rb#1
module MultiJson
include ::MultiJson::Options
extend ::MultiJson::Options
extend ::MultiJson
# Get the current adapter class.
#
# source://multi_json//lib/multi_json.rb#70
def adapter; end
# Set the JSON parser utilizing a symbol, string, or class.
# Supported by default are:
#
# * <tt>:oj</tt>
# * <tt>:json_gem</tt>
# * <tt>:json_pure</tt>
# * <tt>:ok_json</tt>
# * <tt>:yajl</tt>
# * <tt>:nsjsonserialization</tt> (MacRuby only)
# * <tt>:gson</tt> (JRuby only)
# * <tt>:jr_jackson</tt> (JRuby only)
#
# source://multi_json//lib/multi_json.rb#90
def adapter=(new_adapter); end
# source://multi_json//lib/multi_json.rb#26
def cached_options(*_arg0); end
# source://multi_json//lib/multi_json.rb#129
def current_adapter(options = T.unsafe(nil)); end
# Decode a JSON string into Ruby.
#
# <b>Options</b>
#
# <tt>:symbolize_keys</tt> :: If true, will use symbols instead of strings for the keys.
# <tt>:adapter</tt> :: If set, the selected adapter will be used for this call.
#
# source://multi_json//lib/multi_json.rb#119
def decode(string, options = T.unsafe(nil)); end
# The default adapter based on what you currently
# have loaded and installed. First checks to see
# if any adapters are already loaded, then checks
# to see which are installed if none are loaded.
#
# source://multi_json//lib/multi_json.rb#46
def default_adapter; end
# The default adapter based on what you currently
# have loaded and installed. First checks to see
# if any adapters are already loaded, then checks
# to see which are installed if none are loaded.
#
# source://multi_json//lib/multi_json.rb#46
def default_engine; end
# source://multi_json//lib/multi_json.rb#18
def default_options; end
# source://multi_json//lib/multi_json.rb#11
def default_options=(value); end
# Encodes a Ruby object as JSON.
#
# source://multi_json//lib/multi_json.rb#138
def dump(object, options = T.unsafe(nil)); end
# Encodes a Ruby object as JSON.
#
# source://multi_json//lib/multi_json.rb#138
def encode(object, options = T.unsafe(nil)); end
# Get the current adapter class.
#
# source://multi_json//lib/multi_json.rb#70
def engine; end
# Set the JSON parser utilizing a symbol, string, or class.
# Supported by default are:
#
# * <tt>:oj</tt>
# * <tt>:json_gem</tt>
# * <tt>:json_pure</tt>
# * <tt>:ok_json</tt>
# * <tt>:yajl</tt>
# * <tt>:nsjsonserialization</tt> (MacRuby only)
# * <tt>:gson</tt> (JRuby only)
# * <tt>:jr_jackson</tt> (JRuby only)
#
# source://multi_json//lib/multi_json.rb#90
def engine=(new_adapter); end
# Decode a JSON string into Ruby.
#
# <b>Options</b>
#
# <tt>:symbolize_keys</tt> :: If true, will use symbols instead of strings for the keys.
# <tt>:adapter</tt> :: If set, the selected adapter will be used for this call.
#
# source://multi_json//lib/multi_json.rb#119
def load(string, options = T.unsafe(nil)); end
# source://multi_json//lib/multi_json.rb#98
def load_adapter(new_adapter); end
# source://multi_json//lib/multi_json.rb#26
def reset_cached_options!(*_arg0); end
# Set the JSON parser utilizing a symbol, string, or class.
# Supported by default are:
#
# * <tt>:oj</tt>
# * <tt>:json_gem</tt>
# * <tt>:json_pure</tt>
# * <tt>:ok_json</tt>
# * <tt>:yajl</tt>
# * <tt>:nsjsonserialization</tt> (MacRuby only)
# * <tt>:gson</tt> (JRuby only)
# * <tt>:jr_jackson</tt> (JRuby only)
#
# source://multi_json//lib/multi_json.rb#90
def use(new_adapter); end
# Executes passed block using specified adapter.
#
# source://multi_json//lib/multi_json.rb#144
def with_adapter(new_adapter); end
# Executes passed block using specified adapter.
#
# source://multi_json//lib/multi_json.rb#144
def with_engine(new_adapter); end
private
# source://multi_json//lib/multi_json.rb#155
def load_adapter_from_string_name(name); end
end
# source://multi_json//lib/multi_json.rb#31
MultiJson::ALIASES = T.let(T.unsafe(nil), Hash)
# source://multi_json//lib/multi_json/adapter_error.rb#2
class MultiJson::AdapterError < ::ArgumentError
# Returns the value of attribute cause.
#
# source://multi_json//lib/multi_json/adapter_error.rb#3
def cause; end
class << self
# source://multi_json//lib/multi_json/adapter_error.rb#5
def build(original_exception); end
end
end
# Legacy support
#
# source://multi_json//lib/multi_json/parse_error.rb#16
MultiJson::DecodeError = MultiJson::ParseError
# source://multi_json//lib/multi_json/parse_error.rb#16
MultiJson::LoadError = MultiJson::ParseError
# source://multi_json//lib/multi_json/options.rb#2
module MultiJson::Options
# source://multi_json//lib/multi_json/options.rb#25
def default_dump_options; end
# source://multi_json//lib/multi_json/options.rb#21
def default_load_options; end
# source://multi_json//lib/multi_json/options.rb#17
def dump_options(*args); end
# source://multi_json//lib/multi_json/options.rb#8
def dump_options=(options); end
# source://multi_json//lib/multi_json/options.rb#13
def load_options(*args); end
# source://multi_json//lib/multi_json/options.rb#3
def load_options=(options); end
private
# source://multi_json//lib/multi_json/options.rb#31
def get_options(options, *args); end
end
# source://multi_json//lib/multi_json/options_cache.rb#2
module MultiJson::OptionsCache
extend ::MultiJson::OptionsCache
# source://multi_json//lib/multi_json/options_cache.rb#10
def fetch(type, key, &block); end
# source://multi_json//lib/multi_json/options_cache.rb#5
def reset; end
private
# source://multi_json//lib/multi_json/options_cache.rb#24
def write(cache, key); end
end
# Normally MultiJson is used with a few option sets for both dump/load
# methods. When options are generated dynamically though, every call would
# cause a cache miss and the cache would grow indefinitely. To prevent
# this, we just reset the cache every time the number of keys outgrows
# 1000.
#
# source://multi_json//lib/multi_json/options_cache.rb#22
MultiJson::OptionsCache::MAX_CACHE_SIZE = T.let(T.unsafe(nil), Integer)
# source://multi_json//lib/multi_json/parse_error.rb#2
class MultiJson::ParseError < ::StandardError
# Returns the value of attribute cause.
#
# source://multi_json//lib/multi_json/parse_error.rb#3
def cause; end
# Returns the value of attribute data.
#
# source://multi_json//lib/multi_json/parse_error.rb#3
def data; end
class << self
# source://multi_json//lib/multi_json/parse_error.rb#5
def build(original_exception, data); end
end
end
# source://multi_json//lib/multi_json.rb#33
MultiJson::REQUIREMENT_MAP = T.let(T.unsafe(nil), Array)
# source://multi_json//lib/multi_json/version.rb#16
MultiJson::VERSION = T.let(T.unsafe(nil), String)
# source://multi_json//lib/multi_json/version.rb#2
class MultiJson::Version
class << self
# @return [String]
#
# source://multi_json//lib/multi_json/version.rb#10
def to_s; end
end
end
# source://multi_json//lib/multi_json/version.rb#3
MultiJson::Version::MAJOR = T.let(T.unsafe(nil), Integer)
# source://multi_json//lib/multi_json/version.rb#4
MultiJson::Version::MINOR = T.let(T.unsafe(nil), Integer)
# source://multi_json//lib/multi_json/version.rb#5
MultiJson::Version::PATCH = T.let(T.unsafe(nil), Integer)
# source://multi_json//lib/multi_json/version.rb#6
MultiJson::Version::PRE = T.let(T.unsafe(nil), T.untyped)

View File

@@ -40,4 +40,15 @@ class Vips::Image
sig { params(filename: String, opts: T.untyped).returns(String) }
def jpegsave(filename, **opts)
end
sig do
params(
left: Integer,
top: Integer,
width: Integer,
height: Integer,
).returns(Vips::Image)
end
def crop(left, top, width, height)
end
end

View File

@@ -21,6 +21,32 @@ FactoryBot.define do
end
end
trait :gif_blob do
content_type { "image/gif" }
transient do
contents do
File.read(
Rails.root.join(
"test/fixtures/files/images/e621-5418755-animated.gif",
),
)
end
end
end
trait :webm_blob do
content_type { "video/webm" }
transient do
contents do
File.read(
Rails.root.join(
"test/fixtures/files/images/e621-5421402-animated.webm",
),
)
end
end
end
trait :text_blob do
content_type { "text/plain" }
transient { contents { "test content #{SecureRandom.alphanumeric(10)}" } }

View File

@@ -60,6 +60,16 @@ FactoryBot.define do
log_entry { create(:http_log_entry, :text_entry) }
url_str { log_entry.uri_str }
end
trait :gif_file do
url_str { "https://example.com/image.gif" }
log_entry { create(:http_log_entry, :gif_entry) }
end
trait :webm_file do
url_str { "https://example.com/image.webm" }
log_entry { create(:http_log_entry, :webm_entry) }
end
end
FactoryBot.define do

View File

@@ -61,5 +61,15 @@ FactoryBot.define do
content_type { "text/plain" }
response { create(:blob_file, :text_blob) }
end
trait :gif_entry do
content_type { "image/gif" }
response { create(:blob_file, :gif_blob) }
end
trait :webm_entry do
content_type { "video/webm" }
response { create(:blob_file, :webm_blob) }
end
end
end

View File

@@ -0,0 +1,132 @@
# typed: false
# frozen_string_literal: true
require "rails_helper"
require "fileutils"
require "securerandom"
RSpec.describe LoadedMedia do
let(:gif_fixture_path) do
Rails.root.join("test/fixtures/files/images/e621-5418755-animated.gif").to_s
end
let(:jpeg_fixture_path) do
Rails
.root
.join("test/fixtures/files/images/thumb-ac63d9d7-low-quality.jpeg")
.to_s
end
let(:webm_fixture_path) do
Rails
.root
.join("test/fixtures/files/images/e621-5421402-animated.webm")
.to_s
end
let(:thumbnail_options) do
LoadedMedia::ThumbnailOptions.new(
width: 100,
height: 100,
quality: 95,
size: :down,
interlace: true,
for_frames: [0],
)
end
describe ".from_file" do
context "with a gif file" do
it "creates a LoadedMedia::Gif instance" do
media = LoadedMedia.from_file("image/gif", gif_fixture_path)
expect(media).to be_a(LoadedMedia::Gif)
expect(media.num_frames).to eq(37)
end
end
context "with a jpeg file" do
it "creates a LoadedMedia::StaticImage instance" do
media = LoadedMedia.from_file("image/jpeg", jpeg_fixture_path)
expect(media).to be_a(LoadedMedia::StaticImage)
expect(media.num_frames).to eq(1)
end
end
context "with a webm file" do
it "creates a LoadedMedia::Webm instance" do
media = LoadedMedia.from_file("video/webm", webm_fixture_path)
expect(media).to be_a(LoadedMedia::Webm)
expect(media.num_frames).to eq(908)
end
end
context "with an unsupported file type" do
it "returns nil" do
media = LoadedMedia.from_file("application/pdf", jpeg_fixture_path)
expect(media).to be_nil
end
end
end
describe "#write_frame_thumbnail" do
let(:output_dir) do
File.join(BlobFile::TMP_DIR, "loaded_media_spec-#{SecureRandom.uuid}")
end
before { FileUtils.mkdir_p(output_dir) }
after { FileUtils.rm_rf(output_dir) }
def make_output_path(frame)
File.join(output_dir, "thumbnail-#{frame}-#{SecureRandom.uuid}.jpg")
end
context "with a gif file" do
it "can extract and save a thumbnail" do
media = LoadedMedia.from_file("image/gif", gif_fixture_path)
output_path = make_output_path(0)
media.write_frame_thumbnail(0, output_path, thumbnail_options)
expect(File.exist?(output_path)).to be true
expect(File.size(output_path)).to be > 0
end
end
context "with a jpeg file" do
it "can create and save a thumbnail" do
media = LoadedMedia.from_file("image/jpeg", jpeg_fixture_path)
output_path = make_output_path(0)
media.write_frame_thumbnail(0, output_path, thumbnail_options)
expect(File.exist?(output_path)).to be true
expect(File.size(output_path)).to be > 0
end
end
context "with a webm file" do
it "can extract a frame and save a thumbnail" do
media = LoadedMedia.from_file("video/webm", webm_fixture_path)
output_path = make_output_path(0)
media.write_frame_thumbnail(0, output_path, thumbnail_options)
expect(File.exist?(output_path)).to be true
expect(File.size(output_path)).to be > 0
end
it "creates different frames", quiet: false do
media = LoadedMedia.from_file("video/webm", webm_fixture_path)
output_path_1 = make_output_path(0)
output_path_2 = make_output_path(453)
media.write_frame_thumbnail(0, output_path_1, thumbnail_options)
media.write_frame_thumbnail(453, output_path_2, thumbnail_options)
expect(output_path_1).not_to eq(output_path_2)
expect(FileUtils.compare_file(output_path_1, output_path_2)).to be false
end
end
end
end

View File

@@ -33,20 +33,62 @@ RSpec.describe Domain::PostFile::Thumbnail, type: :model do
it "successfully creates a thumbnail from a JPEG image" do
post_file = create(:domain_post_file, :image_file)
# Create a PostFileThumbnail
thumbnail =
described_class.new(post_file:, thumb_type: :size_32_32, frame: 0)
expect(thumbnail.save).to be true
thumbnails = described_class.create_for_post_file!(post_file)
# 1 for the 32x32
# 1 for the content-container
expect(thumbnails.size).to eq(2)
# Verify the thumbnail file was created
expect(thumbnail.absolute_file_path).not_to be_nil
expect(File.exist?(thumbnail.absolute_file_path)).to be true
# expect the 32x32 thumbnail
thumbs_32_32 = post_file.thumbnails.where(thumb_type: :size_32_32)
expect(thumbs_32_32.size).to eq(1)
expect(thumbs_32_32.first.frame).to eq(0)
# Verify the thumbnail has a reasonable size (should be smaller than original)
original_size =
File.size(post_file.log_entry.response.absolute_file_path)
thumbnail_size = File.size(thumbnail.absolute_file_path)
expect(thumbnail_size).to be < original_size
# expect the content-container thumbnail
thumbs_content_container =
post_file.thumbnails.where(thumb_type: :content_container)
expect(thumbs_content_container.size).to eq(1)
expect(thumbs_content_container.first.frame).to eq(0)
end
end
context "with GIF images" do
it "successfully creates a thumbnails from a GIF image" do
# 37 frames in the gif
post_file = create(:domain_post_file, :gif_file)
thumbnails = described_class.create_for_post_file!(post_file)
# 5 for the 32x32
# 1 for content-container
expect(thumbnails.size).to eq(6)
# expect the frames at 0, 0.1, 0.5, 0.9 and 1.0 through the gif
# so frame numbers 0, 3, 18, 32 and 36
thumbs_32_32 = post_file.thumbnails.where(thumb_type: :size_32_32)
expect(thumbs_32_32.map(&:frame)).to eq([0, 3, 18, 32, 36])
# expect the content-container thumbnail
thumbs_content_container =
post_file.thumbnails.where(thumb_type: :content_container)
expect(thumbs_content_container.size).to eq(1)
expect(thumbs_content_container.first.frame).to eq(0)
end
end
context "with webm videos" do
it "successfully creates a thumbnail from a webm video" do
post_file = create(:domain_post_file, :webm_file)
thumbnails = described_class.create_for_post_file!(post_file)
expect(thumbnails.size).to eq(6)
# expect 5x 32x32 thumbnails, 1 for the content-container
thumbs_32_32 = post_file.thumbnails.where(thumb_type: :size_32_32)
expect(thumbs_32_32.size).to eq(5)
expect(thumbs_32_32.map(&:frame)).to eq([0, 90, 453, 816, 907])
# expect the content-container thumbnail
thumbs_content_container =
post_file.thumbnails.where(thumb_type: :content_container)
expect(thumbs_content_container.size).to eq(1)
expect(thumbs_content_container.first.frame).to eq(0)
end
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.