active_storage_validations 0.9.7 → 2.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|