diff options
Diffstat (limited to 'activestorage/lib/active_storage/analyzer')
3 files changed, 176 insertions, 0 deletions
diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer.rb b/activestorage/lib/active_storage/analyzer/image_analyzer.rb new file mode 100644 index 0000000000..3b39de91be --- /dev/null +++ b/activestorage/lib/active_storage/analyzer/image_analyzer.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ActiveStorage + # Extracts width and height in pixels from an image blob. + # + # If the image contains EXIF data indicating its angle is 90 or 270 degrees, its width and height are swapped for convenience. + # + # Example: + # + # ActiveStorage::Analyzer::ImageAnalyzer.new(blob).metadata + # # => { width: 4104, height: 2736 } + # + # This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem. MiniMagick requires + # the {ImageMagick}[http://www.imagemagick.org] system library. + class Analyzer::ImageAnalyzer < Analyzer + def self.accept?(blob) + blob.image? + end + + def metadata + read_image do |image| + if rotated_image?(image) + { width: image.height, height: image.width } + else + { width: image.width, height: image.height } + end + end + rescue LoadError + logger.info "Skipping image analysis because the mini_magick gem isn't installed" + {} + end + + private + def read_image + download_blob_to_tempfile do |file| + require "mini_magick" + yield MiniMagick::Image.new(file.path) + end + end + + def rotated_image?(image) + %w[ RightTop LeftBottom ].include?(image["%[orientation]"]) + end + end +end diff --git a/activestorage/lib/active_storage/analyzer/null_analyzer.rb b/activestorage/lib/active_storage/analyzer/null_analyzer.rb new file mode 100644 index 0000000000..8ff7ce48e5 --- /dev/null +++ b/activestorage/lib/active_storage/analyzer/null_analyzer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ActiveStorage + class Analyzer::NullAnalyzer < Analyzer # :nodoc: + def self.accept?(blob) + true + end + + def metadata + {} + end + end +end diff --git a/activestorage/lib/active_storage/analyzer/video_analyzer.rb b/activestorage/lib/active_storage/analyzer/video_analyzer.rb new file mode 100644 index 0000000000..18d8ff8237 --- /dev/null +++ b/activestorage/lib/active_storage/analyzer/video_analyzer.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module ActiveStorage + # Extracts the following from a video blob: + # + # * Width (pixels) + # * Height (pixels) + # * Duration (seconds) + # * Angle (degrees) + # * Display aspect ratio + # + # Example: + # + # ActiveStorage::VideoAnalyzer.new(blob).metadata + # # => { width: 640.0, height: 480.0, duration: 5.0, angle: 0, display_aspect_ratio: [4, 3] } + # + # When a video's angle is 90 or 270 degrees, its width and height are automatically swapped for convenience. + # + # This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by Rails. + class Analyzer::VideoAnalyzer < Analyzer + def self.accept?(blob) + blob.video? + end + + def metadata + { width: width, height: height, duration: duration, angle: angle, display_aspect_ratio: display_aspect_ratio }.compact + end + + private + def width + if rotated? + computed_height || encoded_height + else + encoded_width + end + end + + def height + if rotated? + encoded_width + else + computed_height || encoded_height + end + end + + def duration + Float(video_stream["duration"]) if video_stream["duration"] + end + + def angle + Integer(tags["rotate"]) if tags["rotate"] + end + + def display_aspect_ratio + if descriptor = video_stream["display_aspect_ratio"] + if terms = descriptor.split(":", 2) + numerator = Integer(terms[0]) + denominator = Integer(terms[1]) + + [numerator, denominator] unless numerator == 0 + end + end + end + + + def rotated? + angle == 90 || angle == 270 + end + + def computed_height + if encoded_width && display_height_scale + encoded_width * display_height_scale + end + end + + def encoded_width + @encoded_width ||= Float(video_stream["width"]) if video_stream["width"] + end + + def encoded_height + @encoded_height ||= Float(video_stream["height"]) if video_stream["height"] + end + + def display_height_scale + @display_height_scale ||= Float(display_aspect_ratio.last) / display_aspect_ratio.first if display_aspect_ratio + end + + + def tags + @tags ||= video_stream["tags"] || {} + end + + def video_stream + @video_stream ||= streams.detect { |stream| stream["codec_type"] == "video" } || {} + end + + def streams + probe["streams"] || [] + end + + def probe + download_blob_to_tempfile { |file| probe_from(file) } + end + + def probe_from(file) + IO.popen([ ffprobe_path, "-print_format", "json", "-show_streams", "-v", "error", file.path ]) do |output| + JSON.parse(output.read) + end + rescue Errno::ENOENT + logger.info "Skipping video analysis because FFmpeg isn't installed" + {} + end + + def ffprobe_path + ActiveStorage.paths[:ffprobe] || "ffprobe" + end + end +end |