Add methods to MediaFile to calculate the duration, frame count, and frame rate of animated GIFs, PNGs, Ugoiras, and videos. Some considerations: * It's possible to have a GIF or PNG that's technically animated but just has one frame. These are treated as non-animated images. * It's possible to have an animated GIF that has an unspecified frame rate. In this case we assume the frame rate is 10 FPS; this is browser dependent and may not be correct. * Animated GIFs, PNGs, and Ugoiras all support variable frame rates. Technically, each frame has a separate delay, and the delays can be different frame-to-frame. We report only the average frame rate. * Getting the duration of an APNG is surprisingly hard. Most tools don't have good support for APNGs since it's a rare and non-standardized format. The best we can do is get the frame count using ExifTool and the frame rate using ffprobe, then calculate the duration from that.
112 lines
3.3 KiB
Ruby
112 lines
3.3 KiB
Ruby
# A MediaFile for a Pixiv ugoira file.
|
|
#
|
|
# A Pixiv ugoira is an animation format that consists of a zip file containing
|
|
# JPEG or PNG images, one per frame, plus a JSON object containing the
|
|
# inter-frame delay timings. Each frame can have a different delay, therefore
|
|
# ugoiras can have a variable framerate. The frame data isn't stored inside the
|
|
# zip file, so it must be passed around separately.
|
|
class MediaFile::Ugoira < MediaFile
|
|
class Error < StandardError; end
|
|
attr_reader :frame_data
|
|
|
|
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
|
|
|
|
def duration
|
|
(frame_delays.sum / 1000.0)
|
|
end
|
|
|
|
def frame_count
|
|
frame_data.count
|
|
end
|
|
|
|
def frame_rate
|
|
frame_count / duration
|
|
end
|
|
|
|
def frame_delays
|
|
frame_data.map { |frame| frame["delay"] }
|
|
end
|
|
|
|
# Convert a ugoira to a webm.
|
|
# 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.videos_enabled?
|
|
raise RuntimeError, "can't convert ugoira to webm: no ugoira frame data was provided" unless frame_data.present?
|
|
|
|
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
|
|
FFmpeg.new(convert).smart_video_preview
|
|
end
|
|
|
|
memoize :zipfile, :preview_frame, :dimensions, :convert
|
|
end
|