Skip to content

Commit

Permalink
Merge pull request #333 from igorkasyanchuk/301-handle-audio-media
Browse files Browse the repository at this point in the history
301 handle audio media
  • Loading branch information
Mth0158 authored Dec 26, 2024
2 parents 2aa783b + 42252fd commit 6bb9b54
Show file tree
Hide file tree
Showing 13 changed files with 168 additions and 64 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ Validates the aspect ratio of the attached files.

The `aspect_ratio` validator has several options:
- `with`: defines the exact allowed aspect ratio (e.g. `16/9`)
🚧 TODO: Add :in option when ready
- `in`: defines the allowed aspect ratios (e.g. `16/9..16/10`)

This validator can define aspect ratios in several ways:
- Symbols:
Expand All @@ -464,6 +464,7 @@ class User < ApplicationRecord
validates :avatar, aspect_ratio: :portrait # restricts the aspect ratio to x:y where y > x
validates :avatar, aspect_ratio: :landscape # restricts the aspect ratio to x:y where x > y
validates :avatar, aspect_ratio: :is_16_9 # restricts the aspect ratio to 16:9
validates :avatar, aspect_ratio: %i[square is_16_9] # restricts the aspect ratio to 1:1 and 16:9
end
```

Expand All @@ -477,6 +478,7 @@ en:
aspect_ratio_not_portrait: "must be a portrait image"
aspect_ratio_not_landscape: "must be a landscape image"
aspect_ratio_is_not: "must have an aspect ratio of %{aspect_ratio}"
aspect_ratio_invalid: "has an invalid aspect ratio"
```

The `aspect_ratio` validator error messages expose 2 values that you can use:
Expand Down
1 change: 1 addition & 0 deletions lib/active_storage_validations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require 'active_storage_validations/analyzer/image_analyzer/vips'
require 'active_storage_validations/analyzer/null_analyzer'
require 'active_storage_validations/analyzer/video_analyzer'
require 'active_storage_validations/analyzer/audio_analyzer'

require 'active_storage_validations/railtie'
require 'active_storage_validations/engine'
Expand Down
58 changes: 58 additions & 0 deletions lib/active_storage_validations/analyzer/audio_analyzer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

require 'open3'
require_relative 'shared/asv_ff_probable'

module ActiveStorageValidations
# = ActiveStorageValidations Audio \Analyzer
#
# Extracts the following from an audio attachable:
#
# * Duration (seconds)
# * Bit rate (bits/s)
# * Sample rate (hertz)
# * Tags (internal metadata)
#
# Example:
#
# ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable).metadata
# # => { duration: 5.0, bit_rate: 320340, sample_rate: 44100, tags: { encoder: "Lavc57.64", ... } }
#
# This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by \Rails.
class Analyzer::AudioAnalyzer < Analyzer
include ASVFFProbable

def metadata
read_media do |media|
{
duration: duration,
bit_rate: bit_rate,
sample_rate: sample_rate,
tags: tags
}.compact
end
end

private

def duration
duration = audio_stream["duration"]
Float(duration).round(1) if duration
end

def bit_rate
bit_rate = audio_stream["bit_rate"]
Integer(bit_rate) if bit_rate
end

def sample_rate
sample_rate = audio_stream["sample_rate"]
Integer(sample_rate) if sample_rate
end

def tags
tags = audio_stream["tags"]
Hash(tags) if tags
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ def media_from_path(path)
# supported attachable.
# We stumbled upon this issue while reading 0 byte size attachable
# https://github.com/janko/image_processing/issues/97
logger.info "Skipping image analysis because Vips doesn't support the file"
nil
end
end
Expand Down
61 changes: 61 additions & 0 deletions lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

module ActiveStorageValidations
# ActiveStorageValidations:::ASVFFProbable
#
# Validator helper methods for analyzers using FFprobe.
module ASVFFProbable
extend ActiveSupport::Concern

private

def read_media
Tempfile.create(binmode: true) do |tempfile|
begin
if media(tempfile).present?
yield media(tempfile)
else
logger.info "Skipping file metadata analysis because ffprobe doesn't support the file"
{}
end
ensure
tempfile.close
end
end
rescue Errno::ENOENT
logger.info "Skipping file metadata analysis because ffprobe isn't installed"
{}
end

def media_from_path(path)
instrument(File.basename(ffprobe_path)) do
stdout, stderr, status = Open3.capture3(
ffprobe_path,
"-print_format", "json",
"-show_streams",
"-show_format",
"-v", "error",
path
)

status.success? ? JSON.parse(stdout) : nil
end
end

def ffprobe_path
ActiveStorage.paths[:ffprobe] || "ffprobe"
end

def video_stream
@video_stream ||= streams.detect { |stream| stream["codec_type"] == "video" } || {}
end

def audio_stream
@audio_stream ||= streams.detect { |stream| stream["codec_type"] == "audio" } || {}
end

def streams
@streams ||= @media["streams"] || []
end
end
end
64 changes: 6 additions & 58 deletions lib/active_storage_validations/analyzer/video_analyzer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require 'open3'
require_relative 'shared/asv_ff_probable'

module ActiveStorageValidations
# = ActiveStorageValidations Video \Analyzer
Expand All @@ -23,11 +24,13 @@ module ActiveStorageValidations
#
# This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by \Rails.
class Analyzer::VideoAnalyzer < Analyzer
include ASVFFProbable

def metadata
read_media do |media|
{
width: Integer(width),
height: Integer(height),
width: (Integer(width) if width),
height: (Integer(height) if height),
duration: duration,
angle: angle,
audio: audio?,
Expand All @@ -38,49 +41,6 @@ def metadata

private

def read_media
Tempfile.create(binmode: true) do |tempfile|
begin
if media(tempfile).present?
yield media(tempfile)
else
logger.info "Skipping image analysis because ImageMagick doesn't support the file"
{}
end
ensure
tempfile.close
end
end
rescue Errno::ENOENT
logger.info "Skipping video analysis because ffprobe isn't installed"
{}
end

def media_from_path(path)
instrument(File.basename(ffprobe_path)) do
stdout, stderr, status = Open3.capture3(
ffprobe_path,
"-print_format", "json",
"-show_streams",
"-show_format",
"-v", "error",
path
)

if status.success?
JSON.parse(stdout)
else
# FFprobe returns an stderr when the file is not valid or not a video.
logger.info "Skipping video analysis because FFprobe doesn't support the file"
nil
end
end
end

def ffprobe_path
ActiveStorage.paths[:ffprobe] || "ffprobe"
end

def width
if rotated?
computed_height || encoded_height
Expand All @@ -99,7 +59,7 @@ def height

def duration
duration = video_stream["duration"] || container["duration"]
Float(duration) if duration
Float(duration).round(1) if duration
end

def angle
Expand Down Expand Up @@ -163,18 +123,6 @@ def side_data
@side_data ||= video_stream["side_data_list"] || {}
end

def video_stream
@video_stream ||= streams.detect { |stream| stream["codec_type"] == "video" } || {}
end

def audio_stream
@audio_stream ||= streams.detect { |stream| stream["codec_type"] == "audio" } || {}
end

def streams
@streams ||= @media["streams"] || []
end

def container
@container ||= @media["format"] || {}
end
Expand Down
5 changes: 5 additions & 0 deletions lib/active_storage_validations/shared/asv_analyzable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def analyzer_for(attachable)
case attachable_media_type(attachable)
when "image" then image_analyzer_for(attachable)
when "video" then video_analyzer_for(attachable)
when "audio" then audio_analyzer_for(attachable)
else fallback_analyzer_for(attachable)
end
end
Expand All @@ -43,6 +44,10 @@ def video_analyzer_for(attachable)
ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable)
end

def audio_analyzer_for(attachable)
ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable)
end

def fallback_analyzer_for(attachable)
ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable)
end
Expand Down
27 changes: 27 additions & 0 deletions test/analyzers/audio_analyzer_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

require "test_helper"
require 'analyzers/support/analyzer_helpers'
require 'analyzers/shared_examples/returns_the_right_metadata_for_any_attachable'

describe ActiveStorageValidations::Analyzer::AudioAnalyzer do
include AnalyzerHelpers

let(:analyzer_klass) { ActiveStorageValidations::Analyzer::AudioAnalyzer }
let(:analyzer) { analyzer_klass.new(attachable) }

let(:media_extension) { '.mp3' }
let(:media_filename) { "audio#{media_extension}" }
let(:media_filename_over_10ko) { "audio_28ko#{media_extension}" }
let(:media_filename_rotated) { "audio#{media_extension}" }
let(:media_filename_0ko) { "audio_0ko#{media_extension}" }
let(:media_path) { Rails.root.join('public', media_filename) }
let(:media_io) { File.open(media_path) }
let(:media_content_type) { 'audio/mp3' }
let(:media_content_type_rotated) { media_content_type }
let(:expected_metadata) { { duration: 1.1, bit_rate: 32000, sample_rate: 44100, tags: { "encoder" => "Lavc60.3."} } }
let(:expected_metadata_over_10ko) { { duration: 2.1, bit_rate: 107141, sample_rate: 44100, tags: { "encoder" => "LAME3.100"} } }
let(:expected_metadata_rotated) { { duration: 1.1, bit_rate: 32000, sample_rate: 44100, tags: { "encoder" => "Lavc60.3."} } }

include ReturnsTheRightMetadataForAnyAttachable
end
6 changes: 3 additions & 3 deletions test/analyzers/video_analyzer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
let(:media_io) { File.open(media_path) }
let(:media_content_type) { 'video/mp4' }
let(:media_content_type_rotated) { media_content_type }
let(:expected_metadata) { { width: 150, height: 150, duration: 1.733333, audio: false, video: true } }
let(:expected_metadata_over_10ko) { { width: 150, height: 150, duration: 9.642967, audio: false, video: true } }
let(:expected_metadata_rotated) { { width: 700, height: 500, duration: 1.733333, audio: false, video: true } }
let(:expected_metadata) { { width: 150, height: 150, duration: 1.7, audio: false, video: true } }
let(:expected_metadata_over_10ko) { { width: 150, height: 150, duration: 9.6, audio: false, video: true } }
let(:expected_metadata_rotated) { { width: 700, height: 500, duration: 1.7, audio: false, video: true } }

include ReturnsTheRightMetadataForAnyAttachable
end
5 changes: 4 additions & 1 deletion test/dummy/app/models/content_type/matcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ class ContentType::Matcher < ApplicationRecord
has_one_attached :allowing_one_with_message
validates :allowing_one_with_message, content_type: { in: ['application/pdf'], message: 'Not authorized file type.' }

most_common_mime_types.each do |content_type|
most_common_mime_types.reject { |common_mime_type| common_mime_type[:type] == :ogv } # issue with ogv
.each do |content_type|
has_one_attached :"#{content_type[:media]}_#{content_type[:type]}"
validates :"#{content_type[:media]}_#{content_type[:type]}",
content_type: content_type[:type]
end
has_one_attached :video_ogv
validates :video_ogv, content_type: ['video/theora']
end
Binary file added test/dummy/public/audio.mp3
Binary file not shown.
Empty file added test/dummy/public/audio_0ko.mp3
Empty file.
Binary file added test/dummy/public/audio_28ko.mp3
Binary file not shown.

0 comments on commit 6bb9b54

Please sign in to comment.