uploads: move thumbnail generation code to MediaFile.

* Move image thumbnail generation code to MediaFile::Image.
* Move video thumbnail generation code to MediaFile::Video.
* Move ugoira->webm conversion code to MediaFile::Ugoira.

This separates thumbnail generation from the upload process so that it's
possible to generate thumbnails outside of uploads.
This commit is contained in:
evazion
2020-05-18 00:31:12 -05:00
parent 24c53172db
commit 45064853de
15 changed files with 269 additions and 423 deletions

View File

@@ -1,36 +0,0 @@
module DanbooruImageResizer
module_function
# Taken from ArgyllCMS 2.0.0 (see also: https://ninedegreesbelow.com/photography/srgb-profile-comparison.html)
SRGB_PROFILE = "#{Rails.root}/config/sRGB.icm"
# http://jcupitt.github.io/libvips/API/current/libvips-resample.html#vips-thumbnail
if Vips.at_least_libvips?(8, 8)
THUMBNAIL_OPTIONS = { size: :down, linear: false, no_rotate: true, export_profile: SRGB_PROFILE, import_profile: SRGB_PROFILE }
CROP_OPTIONS = { linear: false, no_rotate: true, export_profile: SRGB_PROFILE, import_profile: SRGB_PROFILE, crop: :attention }
else
THUMBNAIL_OPTIONS = { size: :down, linear: false, auto_rotate: false, export_profile: SRGB_PROFILE, import_profile: SRGB_PROFILE }
CROP_OPTIONS = { linear: false, auto_rotate: false, export_profile: SRGB_PROFILE, import_profile: SRGB_PROFILE, crop: :attention }
end
# http://jcupitt.github.io/libvips/API/current/VipsForeignSave.html#vips-jpegsave
JPEG_OPTIONS = { background: 255, strip: true, interlace: true, optimize_coding: true }
# https://github.com/jcupitt/libvips/wiki/HOWTO----Image-shrinking
# http://jcupitt.github.io/libvips/API/current/Using-vipsthumbnail.md.html
def resize(file, width, height, resize_quality = 90)
output_file = Tempfile.new
resized_image = Vips::Image.thumbnail(file.path, width, height: height, **THUMBNAIL_OPTIONS)
resized_image.jpegsave(output_file.path, Q: resize_quality, **JPEG_OPTIONS)
output_file
end
def crop(file, width, height, resize_quality = 90)
output_file = Tempfile.new
resized_image = Vips::Image.thumbnail(file.path, width, height: height, **CROP_OPTIONS)
resized_image.jpegsave(output_file.path, Q: resize_quality, **JPEG_OPTIONS)
output_file
end
end

View File

@@ -5,20 +5,20 @@ class MediaFile
# delegate all File methods to `file`.
delegate *(File.instance_methods - MediaFile.instance_methods), to: :file
def self.open(file)
def self.open(file, **options)
file = Kernel.open(file, "r", binmode: true) unless file.respond_to?(:read)
case file_ext(file)
when :jpg, :gif, :png
MediaFile::Image.new(file)
MediaFile::Image.new(file, **options)
when :swf
MediaFile::Flash.new(file)
MediaFile::Flash.new(file, **options)
when :webm, :mp4
MediaFile::Video.new(file)
MediaFile::Video.new(file, **options)
when :zip
MediaFile::Ugoira.new(file)
MediaFile::Ugoira.new(file, **options)
else
MediaFile.new(file)
MediaFile.new(file, **options)
end
end
@@ -45,7 +45,7 @@ class MediaFile
end
end
def initialize(file)
def initialize(file, **options)
@file = file
end
@@ -73,5 +73,29 @@ class MediaFile
file.size
end
def is_image?
file_ext.in?([:jpg, :png, :gif])
end
def is_video?
file_ext.in?([:webm, :mp4])
end
def is_ugoira?
file_ext == :zip
end
def is_flash?
file_ext == :swf
end
def preview(width, height, **options)
nil
end
def crop(width, height, **options)
nil
end
memoize :dimensions, :file_ext, :file_size, :md5
end

View File

@@ -1,7 +1,7 @@
# Adapted from https://github.com/dim/ruby-imagespec/blob/f2f3ce8bb5b1b411f8658e66a891a095261d94c0/lib/image_spec/parser/swf.rb
# License: https://github.com/dim/ruby-imagespec/blob/master/LICENSE
class MediaFile::Flash < MediaFile::Image
class MediaFile::Flash < MediaFile
def dimensions
# Read the entire stream into memory because the
# dimensions aren't stored in a standard location

View File

@@ -1,9 +1,46 @@
class MediaFile::Image < MediaFile
def dimensions
image.size
# Taken from ArgyllCMS 2.0.0 (see also: https://ninedegreesbelow.com/photography/srgb-profile-comparison.html)
SRGB_PROFILE = "#{Rails.root}/config/sRGB.icm"
# http://jcupitt.github.io/libvips/API/current/VipsForeignSave.html#vips-jpegsave
JPEG_OPTIONS = { Q: 90, background: 255, strip: true, interlace: true, optimize_coding: true }
# http://jcupitt.github.io/libvips/API/current/libvips-resample.html#vips-thumbnail
if Vips.at_least_libvips?(8, 8)
THUMBNAIL_OPTIONS = { size: :down, linear: false, no_rotate: true, export_profile: SRGB_PROFILE, import_profile: SRGB_PROFILE }
CROP_OPTIONS = { crop: :attention, linear: false, no_rotate: true, export_profile: SRGB_PROFILE, import_profile: SRGB_PROFILE }
else
THUMBNAIL_OPTIONS = { size: :down, linear: false, auto_rotate: false, export_profile: SRGB_PROFILE, import_profile: SRGB_PROFILE }
CROP_OPTIONS = { crop: :attention, linear: false, auto_rotate: false, export_profile: SRGB_PROFILE, import_profile: SRGB_PROFILE }
end
def dimensions
image.size
rescue Vips::Error
[0, 0]
end
# https://github.com/jcupitt/libvips/wiki/HOWTO----Image-shrinking
# http://jcupitt.github.io/libvips/API/current/Using-vipsthumbnail.md.html
def preview(width, height)
output_file = Tempfile.new(["image-preview", ".jpg"])
resized_image = image.thumbnail_image(width, height: height, **THUMBNAIL_OPTIONS)
resized_image.jpegsave(output_file.path, **JPEG_OPTIONS)
MediaFile::Image.new(output_file)
end
def crop(width, height)
output_file = Tempfile.new(["image-crop", ".jpg"])
resized_image = image.thumbnail_image(width, height: height, **CROP_OPTIONS)
resized_image.jpegsave(output_file.path, **JPEG_OPTIONS)
MediaFile::Image.new(output_file)
end
private
def image
@image ||= Vips::Image.new_from_file(file.path)
@image ||= Vips::Image.new_from_file(file.path, fail: true)
end
end

View File

@@ -1,13 +1,93 @@
class MediaFile::Ugoira < MediaFile
def dimensions
tempfile = Tempfile.new
folder = Zip::File.new(file.path)
folder.first.extract(tempfile.path) { true }
extend Memoist
class Error < StandardError; end
attr_reader :frame_data
image_file = MediaFile.open(tempfile)
image_file.dimensions
ensure
image_file.close
tempfile.close!
def self.conversion_enabled?
system("ffmpeg -version > /dev/null") && system("mkvmerge --version > /dev/null")
end
def initialize(file, frame_data: {}, **options)
super(file, **options)
@frame_data = frame_data
end
def close
file.close
zipfile.close
preview_frame.close
end
def dimensions
preview_frame.dimensions
end
def preview(width, height)
preview_frame.preview(width, height)
end
def crop(width, height)
preview_frame.crop(width, height)
end
# XXX should take width and height and resize image
def convert
raise NotImplementedError, "can't convert ugoira to webm: ffmpeg or mkvmerge not installed" unless self.class.conversion_enabled?
Dir.mktmpdir("ugoira-#{md5}") do |tmpdir|
output_file = Tempfile.new(["ugoira-conversion", ".webm"], binmode: true)
FileUtils.mkdir_p("#{tmpdir}/images")
zipfile.each do |entry|
path = File.join(tmpdir, "images", entry.name)
entry.extract(path)
end
# Duplicate last frame to avoid it being displayed only for a very short amount of time.
last_file_name = zipfile.entries.last.name
last_file_name =~ /\A(\d{6})(\.\w{,4})\Z/
new_last_index = $1.to_i + 1
file_ext = $2
new_last_filename = ("%06d" % new_last_index) + file_ext
path_from = File.join(tmpdir, "images", last_file_name)
path_to = File.join(tmpdir, "images", new_last_filename)
FileUtils.cp(path_from, path_to)
delay_sum = 0
timecodes_path = File.join(tmpdir, "timecodes.tc")
File.open(timecodes_path, "w+") do |f|
f.write("# timecode format v2\n")
frame_data.each do |img|
f.write("#{delay_sum}\n")
delay_sum += (img["delay"] || img["delay_msec"])
end
f.write("#{delay_sum}\n")
f.write("#{delay_sum}\n")
end
ext = zipfile.first.name.match(/\.(\w{,4})$/)[1]
ffmpeg_out, status = Open3.capture2e("ffmpeg -i #{tmpdir}/images/%06d.#{ext} -codec:v libvpx -crf 4 -b:v 5000k -an #{tmpdir}/tmp.webm")
raise Error, "ffmpeg failed: #{ffmpeg_out}" unless status.success?
mkvmerge_out, status = Open3.capture2e("mkvmerge -o #{output_file.path} --webm --timecodes 0:#{tmpdir}/timecodes.tc #{tmpdir}/tmp.webm")
raise Error, "mkvmerge failed: #{mkvmerge_out}" unless status.success?
MediaFile.open(output_file)
end
end
private
def zipfile
Zip::File.new(file.path)
end
def preview_frame
tempfile = Tempfile.new("ugoira-preview", binmode: true)
zipfile.entries.first.extract(tempfile.path) { true } # 'true' means overwrite the existing tempfile.
MediaFile.open(tempfile)
end
memoize :zipfile, :preview_frame
end

View File

@@ -1,9 +1,29 @@
class MediaFile::Video < MediaFile
extend Memoist
def dimensions
[video.width, video.height]
end
def video
@video ||= FFMPEG::Movie.new(file.path)
def preview(max_width, max_height)
preview_frame.preview(max_width, max_height)
end
def crop(max_width, max_height)
preview_frame.crop(max_width, max_height)
end
private
def video
FFMPEG::Movie.new(file.path)
end
def preview_frame
vp = Tempfile.new(["video-preview", ".jpg"], binmode: true)
video.screenshot(vp.path, seek_time: 0)
MediaFile.open(vp.path)
end
memoize :video, :preview_frame
end

View File

@@ -1,90 +0,0 @@
class PixivUgoiraConverter
def self.enabled?
system("ffmpeg -version > /dev/null") && system("mkvmerge --version > /dev/null")
end
def self.generate_webm(ugoira_file, frame_data)
raise NotImplementedError, "can't convert ugoira to webm: ffmpeg or mkvmerge not installed" unless enabled?
folder = Zip::File.new(ugoira_file.path)
output_file = Tempfile.new(binmode: true)
write_path = output_file.path
Dir.mktmpdir do |tmpdir|
FileUtils.mkdir_p("#{tmpdir}/images")
folder.each_with_index do |file, i|
path = File.join(tmpdir, "images", file.name)
file.extract(path)
end
# Duplicate last frame to avoid it being displayed only for a very short amount of time.
last_file_name = folder.to_a.last.name
last_file_name =~ /\A(\d{6})(\.\w{,4})\Z/
new_last_index = $1.to_i + 1
file_ext = $2
new_last_filename = ("%06d" % new_last_index) + file_ext
path_from = File.join(tmpdir, "images", last_file_name)
path_to = File.join(tmpdir, "images", new_last_filename)
FileUtils.cp(path_from, path_to)
delay_sum = 0
timecodes_path = File.join(tmpdir, "timecodes.tc")
File.open(timecodes_path, "w+") do |f|
f.write("# timecode format v2\n")
frame_data.each do |img|
f.write("#{delay_sum}\n")
delay_sum += (img["delay"] || img["delay_msec"])
end
f.write("#{delay_sum}\n")
f.write("#{delay_sum}\n")
end
ext = folder.first.name.match(/\.(\w{,4})$/)[1]
ffmpeg_out, status = Open3.capture2e("ffmpeg -i #{tmpdir}/images/%06d.#{ext} -codec:v libvpx -crf 4 -b:v 5000k -an #{tmpdir}/tmp.webm")
if !status.success?
Rails.logger.error "[write_webm] ******************************"
Rails.logger.error "[write_webm] failed write_path=#{write_path}"
Rails.logger.error "[write_webm] ffmepg output:"
ffmpeg_out.split(/\n/).each do |line|
Rails.logger.error "[write_webm][ffmpeg] #{line}"
end
Rails.logger.error "[write_webm] ******************************"
end
mkvmerge_out, status = Open3.capture2e("mkvmerge -o #{write_path} --webm --timecodes 0:#{tmpdir}/timecodes.tc #{tmpdir}/tmp.webm")
if !status.success?
Rails.logger.error "[write_webm] ******************************"
Rails.logger.error "[write_webm] failed write_path=#{write_path}"
Rails.logger.error "[write_webm] mkvmerge output:"
mkvmerge_out.split(/\n/).each do |line|
Rails.logger.error "[write_webm][mkvmerge] #{line}"
end
Rails.logger.error "[write_webm] ******************************"
end
end
output_file
end
def self.generate_crop(ugoira_file)
file = Tempfile.new(["ugoira-crop", ".zip"], binmode: true)
zipfile = Zip::File.new(ugoira_file.path)
zipfile.entries.first.extract(file.path) { true } # 'true' means overwrite the existing tempfile.
DanbooruImageResizer.crop(file, Danbooru.config.small_image_width, Danbooru.config.small_image_width, 85)
ensure
file&.close!
end
def self.generate_preview(ugoira_file)
file = Tempfile.new(["ugoira-preview", ".zip"], binmode: true)
zipfile = Zip::File.new(ugoira_file.path)
zipfile.entries.first.extract(file.path) { true } # 'true' means overwrite the existing tempfile.
DanbooruImageResizer.resize(file, Danbooru.config.small_image_width, Danbooru.config.small_image_width, 85)
ensure
file.close!
end
end

View File

@@ -25,53 +25,25 @@ class UploadService
source =~ /^https?:\/\//
end
def generate_resizes(file, upload)
if upload.is_video?
video = FFMPEG::Movie.new(file.path)
crop_file = generate_video_crop_for(video, Danbooru.config.small_image_width)
preview_file = generate_video_preview_for(video, Danbooru.config.small_image_width, Danbooru.config.small_image_width)
def generate_resizes(media_file)
preview_file = media_file.preview(Danbooru.config.small_image_width, Danbooru.config.small_image_width)
crop_file = media_file.crop(Danbooru.config.small_image_width, Danbooru.config.small_image_width)
elsif upload.is_ugoira?
preview_file = PixivUgoiraConverter.generate_preview(file)
crop_file = PixivUgoiraConverter.generate_crop(file)
sample_file = PixivUgoiraConverter.generate_webm(file, upload.context["ugoira"]["frame_data"])
elsif upload.is_image?
preview_file = DanbooruImageResizer.resize(file, Danbooru.config.small_image_width, Danbooru.config.small_image_width, 85)
crop_file = DanbooruImageResizer.crop(file, Danbooru.config.small_image_width, Danbooru.config.small_image_width, 85)
if upload.image_width > Danbooru.config.large_image_width
sample_file = DanbooruImageResizer.resize(file, Danbooru.config.large_image_width, upload.image_height, 90)
end
if media_file.is_ugoira?
sample_file = media_file.convert
elsif media_file.is_image? && media_file.width > Danbooru.config.large_image_width
sample_file = media_file.preview(Danbooru.config.large_image_width, nil)
else
sample_file = nil
end
[preview_file, crop_file, sample_file]
end
def generate_video_crop_for(video, width)
vp = Tempfile.new(["video-preview", ".jpg"], binmode: true)
video.screenshot(vp.path, :seek_time => 0, :resolution => "#{video.width}x#{video.height}")
crop = DanbooruImageResizer.crop(vp, width, width, 85)
vp.close
return crop
end
def generate_video_preview_for(video, width, height)
dimension_ratio = video.width.to_f / video.height
if dimension_ratio > 1
height = (width / dimension_ratio).to_i
else
width = (height * dimension_ratio).to_i
end
output_file = Tempfile.new(["video-preview", ".jpg"], binmode: true)
video.screenshot(output_file.path, :seek_time => 0, :resolution => "#{width}x#{height}")
output_file
end
def process_file(upload, file, original_post_id: nil)
media_file = MediaFile.open(file)
upload.file = file
media_file = upload.media_file
upload.file_ext = media_file.file_ext.to_s
upload.file_size = media_file.file_size
upload.md5 = media_file.md5
@@ -81,7 +53,7 @@ class UploadService
upload.validate!(:file)
upload.tag_string = "#{upload.tag_string} #{Utils.automatic_tags(upload, file)}"
preview_file, crop_file, sample_file = Utils.generate_resizes(file, upload)
preview_file, crop_file, sample_file = Utils.generate_resizes(media_file)
begin
Utils.distribute_files(file, upload, :original, original_post_id: original_post_id)

View File

@@ -100,6 +100,10 @@ class Upload < ApplicationRecord
end
module FileMethods
def media_file
@media_file ||= MediaFile.open(file, frame_data: context.to_h.dig("ugoira", "frame_data"))
end
def is_image?
%w(jpg gif png).include?(file_ext)
end