active_storage_validations 0.9.7 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +737 -229
- data/config/locales/da.yml +53 -0
- data/config/locales/de.yml +50 -19
- data/config/locales/en.yml +50 -19
- data/config/locales/es.yml +50 -19
- data/config/locales/fr.yml +50 -19
- data/config/locales/it.yml +50 -19
- data/config/locales/ja.yml +50 -19
- data/config/locales/nl.yml +50 -19
- data/config/locales/pl.yml +50 -19
- data/config/locales/pt-BR.yml +50 -19
- data/config/locales/ru.yml +50 -19
- data/config/locales/sv.yml +53 -0
- data/config/locales/tr.yml +50 -19
- data/config/locales/uk.yml +50 -19
- data/config/locales/vi.yml +50 -19
- data/config/locales/zh-CN.yml +53 -0
- data/lib/active_storage_validations/analyzer/audio_analyzer.rb +58 -0
- data/lib/active_storage_validations/analyzer/content_type_analyzer.rb +60 -0
- data/lib/active_storage_validations/analyzer/image_analyzer/image_magick.rb +47 -0
- data/lib/active_storage_validations/analyzer/image_analyzer/vips.rb +57 -0
- data/lib/active_storage_validations/analyzer/image_analyzer.rb +49 -0
- data/lib/active_storage_validations/analyzer/null_analyzer.rb +18 -0
- data/lib/active_storage_validations/analyzer/shared/asv_ff_probable.rb +61 -0
- data/lib/active_storage_validations/analyzer/video_analyzer.rb +130 -0
- data/lib/active_storage_validations/analyzer.rb +87 -0
- data/lib/active_storage_validations/aspect_ratio_validator.rb +154 -99
- data/lib/active_storage_validations/attached_validator.rb +22 -5
- data/lib/active_storage_validations/base_comparison_validator.rb +71 -0
- data/lib/active_storage_validations/content_type_validator.rb +206 -25
- data/lib/active_storage_validations/dimension_validator.rb +105 -82
- data/lib/active_storage_validations/duration_validator.rb +55 -0
- data/lib/active_storage_validations/extensors/asv_blob_metadatable.rb +49 -0
- data/lib/active_storage_validations/extensors/asv_marcelable.rb +12 -0
- data/lib/active_storage_validations/limit_validator.rb +75 -16
- data/lib/active_storage_validations/matchers/aspect_ratio_validator_matcher.rb +119 -0
- data/lib/active_storage_validations/matchers/attached_validator_matcher.rb +48 -25
- data/lib/active_storage_validations/matchers/base_comparison_validator_matcher.rb +140 -0
- data/lib/active_storage_validations/matchers/content_type_validator_matcher.rb +94 -59
- data/lib/active_storage_validations/matchers/dimension_validator_matcher.rb +97 -55
- data/lib/active_storage_validations/matchers/duration_validator_matcher.rb +39 -0
- data/lib/active_storage_validations/matchers/limit_validator_matcher.rb +127 -0
- data/lib/active_storage_validations/matchers/processable_file_validator_matcher.rb +78 -0
- data/lib/active_storage_validations/matchers/shared/asv_active_storageable.rb +19 -0
- data/lib/active_storage_validations/matchers/shared/asv_allow_blankable.rb +28 -0
- data/lib/active_storage_validations/matchers/shared/asv_attachable.rb +72 -0
- data/lib/active_storage_validations/matchers/shared/asv_contextable.rb +49 -0
- data/lib/active_storage_validations/matchers/shared/asv_messageable.rb +28 -0
- data/lib/active_storage_validations/matchers/shared/asv_rspecable.rb +27 -0
- data/lib/active_storage_validations/matchers/shared/asv_validatable.rb +56 -0
- data/lib/active_storage_validations/matchers/size_validator_matcher.rb +17 -71
- data/lib/active_storage_validations/matchers/total_size_validator_matcher.rb +47 -0
- data/lib/active_storage_validations/matchers.rb +11 -16
- data/lib/active_storage_validations/processable_file_validator.rb +37 -0
- data/lib/active_storage_validations/railtie.rb +11 -0
- data/lib/active_storage_validations/shared/asv_active_storageable.rb +30 -0
- data/lib/active_storage_validations/shared/asv_analyzable.rb +80 -0
- data/lib/active_storage_validations/shared/asv_attachable.rb +204 -0
- data/lib/active_storage_validations/shared/asv_errorable.rb +40 -0
- data/lib/active_storage_validations/shared/asv_loggable.rb +11 -0
- data/lib/active_storage_validations/shared/asv_optionable.rb +29 -0
- data/lib/active_storage_validations/shared/asv_symbolizable.rb +14 -0
- data/lib/active_storage_validations/size_validator.rb +24 -40
- data/lib/active_storage_validations/total_size_validator.rb +51 -0
- data/lib/active_storage_validations/version.rb +1 -1
- data/lib/active_storage_validations.rb +20 -6
- metadata +127 -21
- data/lib/active_storage_validations/metadata.rb +0 -123
data/config/locales/vi.yml
CHANGED
@@ -1,22 +1,53 @@
|
|
1
1
|
vi:
|
2
2
|
errors:
|
3
3
|
messages:
|
4
|
-
content_type_invalid:
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
4
|
+
content_type_invalid:
|
5
|
+
one: "có loại nội dung không hợp lệ (loại nội dung được ủy quyền là %{autorized_human_content_types})"
|
6
|
+
other: "có loại nội dung không hợp lệ (các loại nội dung được ủy quyền là %{autorized_human_content_types})"
|
7
|
+
content_type_spoofed:
|
8
|
+
one: "có một loại nội dung không tương đương với loại được phát hiện thông qua nội dung của nó (loại nội dung được ủy quyền là %{autorized_human_content_types})"
|
9
|
+
other: "có một loại nội dung không tương đương với loại được phát hiện thông qua nội dung của nó (các loại nội dung được ủy quyền là %{autorized_human_content_types})"
|
10
|
+
file_size_not_less_than: "kích thước tệp phải nhỏ hơn %{max} (kích thước hiện tại là %{file_size})"
|
11
|
+
file_size_not_less_than_or_equal_to: "kích thước tệp phải nhỏ hơn hoặc bằng %{max} (kích thước hiện tại là %{file_size})"
|
12
|
+
file_size_not_greater_than: "kích thước tệp phải lớn hơn %{min} (kích thước hiện tại là %{file_size})"
|
13
|
+
file_size_not_greater_than_or_equal_to: "kích thước tệp phải lớn hơn hoặc bằng %{min} (kích thước hiện tại là %{file_size})"
|
14
|
+
file_size_not_between: "kích thước tệp phải nằm trong khoảng từ %{min} đến %{max} (kích thước hiện tại là %{file_size})"
|
15
|
+
total_file_size_not_less_than: "tổng kích thước tệp phải nhỏ hơn %{max} (kích thước hiện tại là %{total_file_size})"
|
16
|
+
total_file_size_not_less_than_or_equal_to: "tổng kích thước tệp phải nhỏ hơn hoặc bằng %{max} (kích thước hiện tại là %{total_file_size})"
|
17
|
+
total_file_size_not_greater_than: "tổng kích thước tệp phải lớn hơn %{min} (kích thước hiện tại là %{total_file_size})"
|
18
|
+
total_file_size_not_greater_than_or_equal_to: "tổng kích thước tệp phải lớn hơn hoặc bằng %{min} (kích thước hiện tại là %{total_file_size})"
|
19
|
+
total_file_size_not_between: "tổng kích thước tệp phải nằm trong khoảng từ %{min} đến %{max} (kích thước hiện tại là %{total_file_size})"
|
20
|
+
duration_not_less_than: "thời lượng phải nhỏ hơn %{max} (thời lượng hiện tại là %{duration})"
|
21
|
+
duration_not_less_than_or_equal_to: "thời lượng phải nhỏ hơn hoặc bằng %{max} (thời lượng hiện tại là %{duration})"
|
22
|
+
duration_not_greater_than: "thời lượng phải lớn hơn %{min} (thời lượng hiện tại là %{duration})"
|
23
|
+
duration_not_greater_than_or_equal_to: "thời lượng phải lớn hơn hoặc bằng %{min} (thời lượng hiện tại là %{duration})"
|
24
|
+
duration_not_between: "thời lượng phải nằm trong khoảng từ %{min} và %{max} (thời lượng hiện tại là %{duration})"
|
25
|
+
limit_out_of_range:
|
26
|
+
zero: "không có tệp nào được đính kèm (phải có giữa các tệp %{min} và %{max})"
|
27
|
+
one: "chỉ có 1 tệp được đính kèm (phải có giữa các tệp %{min} và %{max})"
|
28
|
+
other: "tổng số tệp phải nằm trong khoảng từ %{min} và %{max} tệp (có các tệp %{count} được đính kèm)"
|
29
|
+
limit_min_not_reached:
|
30
|
+
zero: "không có tệp nào được đính kèm (phải có ít nhất %{min} tệp)"
|
31
|
+
one: "chỉ có 1 tệp được đính kèm (phải có ít nhất %{min} tệp)"
|
32
|
+
other: "các tệp %{count} được đính kèm (phải có ít nhất %{min} tệp)"
|
33
|
+
limit_max_exceeded:
|
34
|
+
zero: "không có tệp nào được đính kèm (tối đa là các tệp %{max})"
|
35
|
+
one: "quá nhiều tệp được đính kèm (tối đa là các tệp %{max}, có %{count})"
|
36
|
+
other: "quá nhiều tệp được đính kèm (tối đa là các tệp %{max}, có %{count})"
|
37
|
+
media_metadata_missing: "không phải là ảnh"
|
38
|
+
dimension_min_not_included_in: "phải lớn hơn hoặc bằng %{width} x %{height} pixel"
|
39
|
+
dimension_max_not_included_in: "phải nhỏ hơn hoặc bằng %{width} x %{height} pixel"
|
40
|
+
dimension_width_not_included_in: "chiều rộng không nằm trong %{min} và %{max} pixel"
|
41
|
+
dimension_height_not_included_in: "chiều cao không nằm trong %{min} và %{max} pixel"
|
42
|
+
dimension_width_not_greater_than_or_equal_to: "chiều rộng phải lớn hơn hoặc bằng %{length} pixel"
|
43
|
+
dimension_height_not_greater_than_or_equal_to: "chiều cao phải lớn hơn hoặc bằng %{length} pixel"
|
44
|
+
dimension_width_not_less_than_or_equal_to: "chiều rộng phải nhỏ hơn hoặc bằng %{length} pixel"
|
45
|
+
dimension_height_not_less_than_or_equal_to: "chiều cao phải nhỏ hơn hoặc bằng %{length} pixel"
|
46
|
+
dimension_width_not_equal_to: "chiều rộng phải bằng %{length} pixel"
|
47
|
+
dimension_height_not_equal_to: "chiều cao phải bằng %{length} pixel"
|
48
|
+
aspect_ratio_not_square: "phải là hình vuông (tệp hiện tại là %{width}x%{height}px)"
|
49
|
+
aspect_ratio_not_portrait: "phải là chân dung (tệp hiện tại là %{width}x%{height}px)"
|
50
|
+
aspect_ratio_not_landscape: "phải là ngang (tệp hiện tại là %{width}x%{height}px)"
|
51
|
+
aspect_ratio_not_x_y: "phải là %{authorized_aspect_ratios} (tệp hiện tại là %{width}x%{height}px)"
|
52
|
+
aspect_ratio_invalid: "có tỷ lệ khung hình không hợp lệ (tỷ lệ khung hình hợp lệ là %{authorized_aspect_ratios})"
|
53
|
+
file_not_processable: "không được xác định là tệp phương tiện hợp lệ"
|
@@ -0,0 +1,53 @@
|
|
1
|
+
zh-CN:
|
2
|
+
errors:
|
3
|
+
messages:
|
4
|
+
content_type_invalid:
|
5
|
+
one: "具有无效的内容类型(授权的内容类型为%{authorized_human_content_types})"
|
6
|
+
other: "具有无效的内容类型(授权的内容类型为%{authorized_human_content_types})"
|
7
|
+
content_type_spoofed:
|
8
|
+
one: "其内容类型不等于通过其内容检测到的内容类型(授权内容类型为%{authorized_human_content_types})"
|
9
|
+
other: "其内容类型与通过其内容检测到的内容类型不等效(授权内容类型为%{authorized_human_content_types})"
|
10
|
+
file_size_not_less_than: "文件大小必须小于 %{max}(当前大小为 %{file_size})"
|
11
|
+
file_size_not_less_than_or_equal_to: "文件大小必须小于或等于 %{max}(当前大小为 %{file_size})"
|
12
|
+
file_size_not_greater_than: "文件大小必须大于 %{min}(当前大小为 %{file_size})"
|
13
|
+
file_size_not_greater_than_or_equal_to: "文件大小必须大于或等于 %{min}(当前大小为 %{file_size})"
|
14
|
+
file_size_not_between: "文件大小必须介于 %{min} 和 %{max} 之间(当前大小为 %{file_size})"
|
15
|
+
total_file_size_not_less_than: "总文件大小必须小于 %{max}(当前大小为 %{total_file_size})"
|
16
|
+
total_file_size_not_less_than_or_equal_to: "文件总大小必须小于或等于 %{max}(当前大小为 %{total_file_size})"
|
17
|
+
total_file_size_not_greater_than: "总文件大小必须大于 %{min}(当前大小为 %{total_file_size})"
|
18
|
+
total_file_size_not_greater_than_or_equal_to: "文件总大小必须大于或等于 %{min}(当前大小为 %{total_file_size})"
|
19
|
+
total_file_size_not_between: "总文件大小必须介于 %{min} 和 %{max} 之间(当前大小为 %{total_file_size})"
|
20
|
+
duration_not_less_than: "持续时间必须小于%{max}(当前持续时间为%{duration})"
|
21
|
+
duration_not_less_than_or_equal_to: "持续时间必须小于或等于%{max}(当前持续时间为%{duration})"
|
22
|
+
duration_not_greater_than: "持续时间必须大于%{min}(当前持续时间为%{duration})"
|
23
|
+
duration_not_greater_than_or_equal_to: "持续时间必须大于或等于%{min}(当前持续时间为%{duration})"
|
24
|
+
duration_not_between: "持续时间必须在%{min}和%{max}之间(当前持续时间为%{duration})"
|
25
|
+
limit_out_of_range:
|
26
|
+
zero: "未附加文件(必须具有%{min}和%{max}文件之间)"
|
27
|
+
one: "仅附加1个文件(必须具有%{min}和%{max}文件之间)"
|
28
|
+
other: "文件总数必须在%{min}和%{max}文件之间(已连接%{count}文件)"
|
29
|
+
limit_min_not_reached:
|
30
|
+
zero: "未附加文件(必须至少具有%{min}文件)"
|
31
|
+
one: "仅附加1个文件(必须至少具有%{min}文件)"
|
32
|
+
other: "%{count}文件已连接(必须至少具有%{min}文件)"
|
33
|
+
limit_max_exceeded:
|
34
|
+
zero: "未附加文件(最大为%{max}文件)"
|
35
|
+
one: "附加的文件太多(最大为%{max}文件,得到%{count})"
|
36
|
+
other: "附加的文件太多(最大为%{max}文件,得到%{count})"
|
37
|
+
media_metadata_missing: "不是有效的媒体文件"
|
38
|
+
dimension_min_not_included_in: "必须大于或等于 %{width} x %{height} 像素"
|
39
|
+
dimension_max_not_included_in: "必须小于或等于 %{width} x %{height} 像素"
|
40
|
+
dimension_width_not_included_in: "宽度不在 %{min} 和 %{max} 像素之间"
|
41
|
+
dimension_height_not_included_in: "高度不在 %{min} 和 %{max} 像素之间"
|
42
|
+
dimension_width_not_greater_than_or_equal_to: "宽度必须大于或等于 %{length} 像素"
|
43
|
+
dimension_height_not_greater_than_or_equal_to: "高度必须大于或等于 %{length} 像素"
|
44
|
+
dimension_width_not_less_than_or_equal_to: "宽度必须小于或等于 %{length} 像素"
|
45
|
+
dimension_height_not_less_than_or_equal_to: "高度必须小于或等于 %{length} 像素"
|
46
|
+
dimension_width_not_equal_to: "宽度必须等于 %{length} 像素"
|
47
|
+
dimension_height_not_equal_to: "高度必须等于 %{length} 像素"
|
48
|
+
aspect_ratio_not_square: "必须为正方形(当前文件为 %{width} x%{height} 像素)"
|
49
|
+
aspect_ratio_not_portrait: "必须是纵向的(当前文件为 %{width} x%{height} 像素)"
|
50
|
+
aspect_ratio_not_landscape: "必须是横向(当前文件为 %{width} x%{height} 像素)"
|
51
|
+
expect_ratio_not_x_y: "必须为 %{authorized_aspect_ratios}(当前文件为 %{width} x%{height} 像素)"
|
52
|
+
expect_ratio_not_in_list: "具有无效的长宽比(有效长宽比为 %{authorized_aspect_ratios})"
|
53
|
+
file_not_processable: "未标识为有效的媒体文件"
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
require_relative 'shared/asv_ff_probable'
|
5
|
+
|
6
|
+
module ActiveStorageValidations
|
7
|
+
# = ActiveStorageValidations Audio \Analyzer
|
8
|
+
#
|
9
|
+
# Extracts the following from an audio attachable:
|
10
|
+
#
|
11
|
+
# * Duration (seconds)
|
12
|
+
# * Bit rate (bits/s)
|
13
|
+
# * Sample rate (hertz)
|
14
|
+
# * Tags (internal metadata)
|
15
|
+
#
|
16
|
+
# Example:
|
17
|
+
#
|
18
|
+
# ActiveStorageValidations::Analyzer::AudioAnalyzer.new(attachable).metadata
|
19
|
+
# # => { duration: 5.0, bit_rate: 320340, sample_rate: 44100, tags: { encoder: "Lavc57.64", ... } }
|
20
|
+
#
|
21
|
+
# This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by \Rails.
|
22
|
+
class Analyzer::AudioAnalyzer < Analyzer
|
23
|
+
include ASVFFProbable
|
24
|
+
|
25
|
+
def metadata
|
26
|
+
read_media do |media|
|
27
|
+
{
|
28
|
+
duration: duration,
|
29
|
+
bit_rate: bit_rate,
|
30
|
+
sample_rate: sample_rate,
|
31
|
+
tags: tags
|
32
|
+
}.compact
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def duration
|
39
|
+
duration = audio_stream["duration"]
|
40
|
+
Float(duration).round(1) if duration
|
41
|
+
end
|
42
|
+
|
43
|
+
def bit_rate
|
44
|
+
bit_rate = audio_stream["bit_rate"]
|
45
|
+
Integer(bit_rate) if bit_rate
|
46
|
+
end
|
47
|
+
|
48
|
+
def sample_rate
|
49
|
+
sample_rate = audio_stream["sample_rate"]
|
50
|
+
Integer(sample_rate) if sample_rate
|
51
|
+
end
|
52
|
+
|
53
|
+
def tags
|
54
|
+
tags = audio_stream["tags"]
|
55
|
+
Hash(tags) if tags
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
|
5
|
+
module ActiveStorageValidations
|
6
|
+
# = ActiveStorageValidations ContentType \Analyzer
|
7
|
+
#
|
8
|
+
# Extracts the content type from an attachable. This is used to prevent content
|
9
|
+
# type spoofing.
|
10
|
+
#
|
11
|
+
# Example:
|
12
|
+
#
|
13
|
+
# ActiveStorageValidations::Analyzer::ContentTypeAnalyzer.new(attachable).content_type
|
14
|
+
# # => { content_type: "image/png" }
|
15
|
+
#
|
16
|
+
# This analyzer requires the {UNIX file}[https://en.wikipedia.org/wiki/File_(command)] command, which is not provided by \Rails. While it is available on most UNIX distributions, it may need to be installed explicitly on minimal or custom setups.
|
17
|
+
class Analyzer::ContentTypeAnalyzer < Analyzer
|
18
|
+
class FileCommandLineToolNotInstalledError < StandardError; end
|
19
|
+
|
20
|
+
def content_type
|
21
|
+
read_media do |media|
|
22
|
+
{
|
23
|
+
content_type: media
|
24
|
+
}
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def read_media
|
31
|
+
Tempfile.create(binmode: true) do |tempfile|
|
32
|
+
begin
|
33
|
+
if media(tempfile).present?
|
34
|
+
yield media(tempfile)
|
35
|
+
else
|
36
|
+
logger.info "Skipping file content_type analysis because Linux file command doesn't support the file"
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
ensure
|
40
|
+
tempfile.close
|
41
|
+
end
|
42
|
+
end
|
43
|
+
rescue Errno::ENOENT
|
44
|
+
raise FileCommandLineToolNotInstalledError, 'file command-line tool is not installed'
|
45
|
+
end
|
46
|
+
|
47
|
+
def media_from_path(path)
|
48
|
+
instrument("file") do
|
49
|
+
stdout, status = Open3.capture2(
|
50
|
+
'file',
|
51
|
+
'-b',
|
52
|
+
'--mime-type',
|
53
|
+
path
|
54
|
+
)
|
55
|
+
|
56
|
+
status.success? ? stdout.strip : nil
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveStorageValidations
|
4
|
+
# This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem.
|
5
|
+
# MiniMagick requires the {ImageMagick}[http://www.imagemagick.org] system library.
|
6
|
+
# This is the default Rails image analyzer.
|
7
|
+
class Analyzer::ImageAnalyzer::ImageMagick < Analyzer::ImageAnalyzer
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def read_media
|
12
|
+
Tempfile.create(binmode: true) do |tempfile|
|
13
|
+
begin
|
14
|
+
if media(tempfile).valid?
|
15
|
+
yield media(tempfile)
|
16
|
+
else
|
17
|
+
logger.info "Skipping image analysis because ImageMagick doesn't support the file"
|
18
|
+
{}
|
19
|
+
end
|
20
|
+
ensure
|
21
|
+
tempfile.close
|
22
|
+
end
|
23
|
+
end
|
24
|
+
rescue MiniMagick::Error => error
|
25
|
+
logger.error "Skipping image analysis due to an ImageMagick error: #{error.message}"
|
26
|
+
{}
|
27
|
+
end
|
28
|
+
|
29
|
+
def media_from_path(path)
|
30
|
+
instrument("mini_magick") do
|
31
|
+
MiniMagick::Image.new(path)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def rotated_image?(image)
|
36
|
+
%w[ RightTop LeftBottom TopRight BottomLeft ].include?(image["%[orientation]"])
|
37
|
+
end
|
38
|
+
|
39
|
+
def supported?
|
40
|
+
require "mini_magick"
|
41
|
+
true
|
42
|
+
rescue LoadError
|
43
|
+
logger.info "Skipping image analysis because the mini_magick gem isn't installed"
|
44
|
+
false
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveStorageValidations
|
4
|
+
# This analyzer relies on the third-party {ruby-vips}[https://github.com/libvips/ruby-vips] gem.
|
5
|
+
# Ruby-vips requires the {libvips}[https://libvips.github.io/libvips/] system library.
|
6
|
+
class Analyzer::ImageAnalyzer::Vips < Analyzer::ImageAnalyzer
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def read_media
|
11
|
+
Tempfile.create(binmode: true) do |tempfile|
|
12
|
+
begin
|
13
|
+
if media(tempfile)
|
14
|
+
yield media(tempfile)
|
15
|
+
else
|
16
|
+
logger.info "Skipping image analysis because Vips doesn't support the file"
|
17
|
+
{}
|
18
|
+
end
|
19
|
+
ensure
|
20
|
+
tempfile.close
|
21
|
+
end
|
22
|
+
end
|
23
|
+
rescue ::Vips::Error => error
|
24
|
+
logger.error "Skipping image analysis due to a Vips error: #{error.message}"
|
25
|
+
{}
|
26
|
+
end
|
27
|
+
|
28
|
+
def media_from_path(path)
|
29
|
+
instrument("vips") do
|
30
|
+
begin
|
31
|
+
::Vips::Image.new_from_file(path, access: :sequential)
|
32
|
+
rescue ::Vips::Error
|
33
|
+
# Vips throw errors rather than returning false when reading a not
|
34
|
+
# supported attachable.
|
35
|
+
# We stumbled upon this issue while reading 0 byte size attachable
|
36
|
+
# https://github.com/janko/image_processing/issues/97
|
37
|
+
nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
ROTATIONS = /Right-top|Left-bottom|Top-right|Bottom-left/
|
43
|
+
def rotated_image?(image)
|
44
|
+
ROTATIONS === image.get("exif-ifd0-Orientation")
|
45
|
+
rescue ::Vips::Error
|
46
|
+
false
|
47
|
+
end
|
48
|
+
|
49
|
+
def supported?
|
50
|
+
require "vips"
|
51
|
+
true
|
52
|
+
rescue LoadError
|
53
|
+
logger.info "Skipping image analysis because the ruby-vips gem isn't installed"
|
54
|
+
false
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveStorageValidations
|
4
|
+
# = ActiveStorageValidations Image \Analyzer
|
5
|
+
#
|
6
|
+
# This is an abstract base class for image analyzers, which extract width and height from an image attachable.
|
7
|
+
#
|
8
|
+
# If the image contains EXIF data indicating its angle is 90 or 270 degrees, its width and height are swapped for convenience.
|
9
|
+
#
|
10
|
+
# Example:
|
11
|
+
#
|
12
|
+
# ActiveStorageValidations::Analyzer::ImageAnalyzer::ImageMagick.new(attachable).metadata
|
13
|
+
# # => { width: 4104, height: 2736 }
|
14
|
+
class Analyzer::ImageAnalyzer < Analyzer
|
15
|
+
@@supported_analyzers = {}
|
16
|
+
|
17
|
+
def metadata
|
18
|
+
return {} unless analyzer_supported?
|
19
|
+
|
20
|
+
read_media do |media|
|
21
|
+
if rotated_image?(media)
|
22
|
+
{ width: media.height, height: media.width }
|
23
|
+
else
|
24
|
+
{ width: media.width, height: media.height }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def analyzer_supported?
|
32
|
+
if @@supported_analyzers.key?(self)
|
33
|
+
@@supported_analyzers.fetch(self)
|
34
|
+
else
|
35
|
+
@@supported_analyzers[self] = supported?
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Override this method in a concrete subclass. Have it return true if the image is rotated.
|
40
|
+
def rotated_image?(media)
|
41
|
+
raise NotImplementedError
|
42
|
+
end
|
43
|
+
|
44
|
+
# Override this method in a concrete subclass. Have it return true if the analyzer is supported.
|
45
|
+
def supported?
|
46
|
+
raise NotImplementedError
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveStorageValidations
|
4
|
+
# = ActiveStorageValidations Null Analyzer
|
5
|
+
#
|
6
|
+
# This is a fallback analyzer when the attachable media type is not supported
|
7
|
+
# by our gem.
|
8
|
+
#
|
9
|
+
# Example:
|
10
|
+
#
|
11
|
+
# ActiveStorageValidations::Analyzer::NullAnalyzer.new(attachable).metadata
|
12
|
+
# # => {}
|
13
|
+
class Analyzer::NullAnalyzer < Analyzer
|
14
|
+
def metadata
|
15
|
+
{}
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveStorageValidations
|
4
|
+
# ActiveStorageValidations:::ASVFFProbable
|
5
|
+
#
|
6
|
+
# Validator helper methods for analyzers using FFprobe.
|
7
|
+
module ASVFFProbable
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def read_media
|
13
|
+
Tempfile.create(binmode: true) do |tempfile|
|
14
|
+
begin
|
15
|
+
if media(tempfile).present?
|
16
|
+
yield media(tempfile)
|
17
|
+
else
|
18
|
+
logger.info "Skipping file metadata analysis because ffprobe doesn't support the file"
|
19
|
+
{}
|
20
|
+
end
|
21
|
+
ensure
|
22
|
+
tempfile.close
|
23
|
+
end
|
24
|
+
end
|
25
|
+
rescue Errno::ENOENT
|
26
|
+
logger.info "Skipping file metadata analysis because ffprobe isn't installed"
|
27
|
+
{}
|
28
|
+
end
|
29
|
+
|
30
|
+
def media_from_path(path)
|
31
|
+
instrument(File.basename(ffprobe_path)) do
|
32
|
+
stdout, _stderr, status = Open3.capture3(
|
33
|
+
ffprobe_path,
|
34
|
+
"-print_format", "json",
|
35
|
+
"-show_streams",
|
36
|
+
"-show_format",
|
37
|
+
"-v", "error",
|
38
|
+
path
|
39
|
+
)
|
40
|
+
|
41
|
+
status.success? ? JSON.parse(stdout) : nil
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def ffprobe_path
|
46
|
+
ActiveStorage.paths[:ffprobe] || "ffprobe"
|
47
|
+
end
|
48
|
+
|
49
|
+
def video_stream
|
50
|
+
@video_stream ||= streams.detect { |stream| stream["codec_type"] == "video" } || {}
|
51
|
+
end
|
52
|
+
|
53
|
+
def audio_stream
|
54
|
+
@audio_stream ||= streams.detect { |stream| stream["codec_type"] == "audio" } || {}
|
55
|
+
end
|
56
|
+
|
57
|
+
def streams
|
58
|
+
@streams ||= @media["streams"] || []
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open3'
|
4
|
+
require_relative 'shared/asv_ff_probable'
|
5
|
+
|
6
|
+
module ActiveStorageValidations
|
7
|
+
# = ActiveStorageValidations Video \Analyzer
|
8
|
+
#
|
9
|
+
# Extracts the following from a video attachable:
|
10
|
+
#
|
11
|
+
# * Width (pixels)
|
12
|
+
# * Height (pixels)
|
13
|
+
# * Duration (seconds)
|
14
|
+
# * Angle (degrees)
|
15
|
+
# * Audio (true if file has an audio channel, false if not)
|
16
|
+
# * Video (true if file has an video channel, false if not)
|
17
|
+
#
|
18
|
+
# Example:
|
19
|
+
#
|
20
|
+
# ActiveStorageValidations::Analyzer::VideoAnalyzer.new(attachable).metadata
|
21
|
+
# # => { width: 640, height: 480, duration: 5.0, angle: 0, audio: true, video: true }
|
22
|
+
#
|
23
|
+
# When a video's angle is 90, -90, 270 or -270 degrees, its width and height are automatically swapped for convenience.
|
24
|
+
#
|
25
|
+
# This analyzer requires the {FFmpeg}[https://www.ffmpeg.org] system library, which is not provided by \Rails.
|
26
|
+
class Analyzer::VideoAnalyzer < Analyzer
|
27
|
+
include ASVFFProbable
|
28
|
+
|
29
|
+
def metadata
|
30
|
+
read_media do |media|
|
31
|
+
{
|
32
|
+
width: (Integer(width) if width),
|
33
|
+
height: (Integer(height) if height),
|
34
|
+
duration: duration,
|
35
|
+
angle: angle,
|
36
|
+
audio: audio?,
|
37
|
+
video: video?
|
38
|
+
}.compact
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def width
|
45
|
+
if rotated?
|
46
|
+
computed_height || encoded_height
|
47
|
+
else
|
48
|
+
encoded_width
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def height
|
53
|
+
if rotated?
|
54
|
+
encoded_width
|
55
|
+
else
|
56
|
+
computed_height || encoded_height
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def duration
|
61
|
+
duration = video_stream["duration"] || container["duration"]
|
62
|
+
Float(duration).round(1) if duration
|
63
|
+
end
|
64
|
+
|
65
|
+
def angle
|
66
|
+
if tags["rotate"]
|
67
|
+
Integer(tags["rotate"])
|
68
|
+
elsif display_matrix && display_matrix["rotation"]
|
69
|
+
Integer(display_matrix["rotation"])
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def display_matrix
|
74
|
+
side_data.detect { |data| data["side_data_type"] == "Display Matrix" }
|
75
|
+
end
|
76
|
+
|
77
|
+
def display_aspect_ratio
|
78
|
+
if descriptor = video_stream["display_aspect_ratio"]
|
79
|
+
if terms = descriptor.split(":", 2)
|
80
|
+
numerator = Integer(terms[0])
|
81
|
+
denominator = Integer(terms[1])
|
82
|
+
|
83
|
+
[numerator, denominator] unless numerator == 0
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def rotated?
|
89
|
+
angle == 90 || angle == 270 || angle == -90 || angle == -270
|
90
|
+
end
|
91
|
+
|
92
|
+
def audio?
|
93
|
+
audio_stream.present?
|
94
|
+
end
|
95
|
+
|
96
|
+
def video?
|
97
|
+
video_stream.present?
|
98
|
+
end
|
99
|
+
|
100
|
+
def computed_height
|
101
|
+
if encoded_width && display_height_scale
|
102
|
+
encoded_width * display_height_scale
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def encoded_width
|
107
|
+
@encoded_width ||= Float(video_stream["width"]) if video_stream["width"]
|
108
|
+
end
|
109
|
+
|
110
|
+
def encoded_height
|
111
|
+
@encoded_height ||= Float(video_stream["height"]) if video_stream["height"]
|
112
|
+
end
|
113
|
+
|
114
|
+
def display_height_scale
|
115
|
+
@display_height_scale ||= Float(display_aspect_ratio.last) / display_aspect_ratio.first if display_aspect_ratio
|
116
|
+
end
|
117
|
+
|
118
|
+
def tags
|
119
|
+
@tags ||= video_stream["tags"] || {}
|
120
|
+
end
|
121
|
+
|
122
|
+
def side_data
|
123
|
+
@side_data ||= video_stream["side_data_list"] || {}
|
124
|
+
end
|
125
|
+
|
126
|
+
def container
|
127
|
+
@container ||= @media["format"] || {}
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|