dynamic_image 3.0.9 → 3.1.0
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 +393 -67
- data/app/models/dynamic_image/variant.rb +10 -0
- data/lib/dynamic_image/backfill.rb +85 -0
- data/lib/dynamic_image/belongs_to.rb +22 -0
- data/lib/dynamic_image/breakpoints.rb +93 -0
- data/lib/dynamic_image/controller.rb +51 -7
- data/lib/dynamic_image/digest_verifier.rb +20 -9
- data/lib/dynamic_image/engine.rb +3 -0
- data/lib/dynamic_image/errors.rb +30 -0
- data/lib/dynamic_image/format.rb +149 -8
- data/lib/dynamic_image/format_negotiator.rb +84 -0
- data/lib/dynamic_image/helper/formats.rb +57 -0
- data/lib/dynamic_image/helper/pictures.rb +124 -0
- data/lib/dynamic_image/helper.rb +100 -60
- data/lib/dynamic_image/image_processor/colors.rb +3 -7
- data/lib/dynamic_image/image_processor/frames.rb +10 -1
- data/lib/dynamic_image/image_processor/transform.rb +17 -2
- data/lib/dynamic_image/image_processor.rb +33 -6
- data/lib/dynamic_image/image_reader.rb +19 -1
- data/lib/dynamic_image/image_sizing.rb +51 -19
- data/lib/dynamic_image/metadata.rb +56 -5
- data/lib/dynamic_image/model/dimensions.rb +32 -13
- data/lib/dynamic_image/model/transformations.rb +47 -11
- data/lib/dynamic_image/model/validations.rb +15 -8
- data/lib/dynamic_image/model/variants.rb +6 -3
- data/lib/dynamic_image/model.rb +58 -34
- data/lib/dynamic_image/picture/format_policy.rb +72 -0
- data/lib/dynamic_image/picture.rb +215 -0
- data/lib/dynamic_image/processed_image.rb +48 -14
- data/lib/dynamic_image/ratio.rb +43 -0
- data/lib/dynamic_image/routing.rb +14 -3
- data/lib/dynamic_image/schema.rb +47 -0
- data/lib/dynamic_image/version.rb +1 -1
- data/lib/dynamic_image.rb +61 -1
- data/lib/rails/generators/dynamic_image/resource/USAGE +17 -0
- data/lib/rails/generators/dynamic_image/resource/resource_generator.rb +57 -21
- data/lib/rails/generators/dynamic_image/resource/templates/create_table_migration.rb.tt +32 -0
- data/lib/rails/generators/dynamic_image/upgrade/USAGE +17 -0
- data/lib/rails/generators/dynamic_image/upgrade/templates/upgrade_migration.rb.tt +10 -0
- data/lib/rails/generators/dynamic_image/upgrade/upgrade_generator.rb +111 -0
- data/lib/tasks/dynamic_image.rake +25 -0
- metadata +23 -12
- data/lib/dynamic_image/jobs/create_variant.rb +0 -22
- data/lib/dynamic_image/jobs.rb +0 -3
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DynamicImage
|
|
4
|
+
class Picture
|
|
5
|
+
# = DynamicImage Picture Format Policy
|
|
6
|
+
#
|
|
7
|
+
# Decides what a responsive image is rendered in. The <tt>source</tt> gets the best format available for
|
|
8
|
+
# output, transcoded unconditionally. The <tt>img</tt> gets one of {DynamicImage::COMPATIBLE_FORMATS}.
|
|
9
|
+
#
|
|
10
|
+
# @see DynamicImage::FormatNegotiator
|
|
11
|
+
class FormatPolicy
|
|
12
|
+
# The format the candidates are rendered in when the view doesn't ask for one.
|
|
13
|
+
DEFAULT_SOURCE_FORMAT = :webp
|
|
14
|
+
|
|
15
|
+
# @!attribute [r] record
|
|
16
|
+
# @return [DynamicImage::Model]
|
|
17
|
+
# @!attribute [r] requested
|
|
18
|
+
# @return [Symbol, Array<Symbol>, nil]
|
|
19
|
+
attr_reader :record, :requested
|
|
20
|
+
|
|
21
|
+
# @param record [DynamicImage::Model] the image
|
|
22
|
+
# @param requested [Symbol, Array<Symbol>, nil] the format the candidates are rendered in. A symbol forces
|
|
23
|
+
# that format, an array is negotiated, nil takes the default.
|
|
24
|
+
def initialize(record, requested = nil)
|
|
25
|
+
@record = record
|
|
26
|
+
@requested = requested
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The format the candidates are rendered in.
|
|
30
|
+
#
|
|
31
|
+
# @return [DynamicImage::Format]
|
|
32
|
+
def source
|
|
33
|
+
@source ||= resolve(requested || default_source)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The format the fallback image is rendered in.
|
|
37
|
+
#
|
|
38
|
+
# @return [DynamicImage::Format]
|
|
39
|
+
def fallback
|
|
40
|
+
@fallback ||= resolve(fallback_formats)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def default_source
|
|
46
|
+
record.animated? ? fallback_formats : DEFAULT_SOURCE_FORMAT
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def resolve(value)
|
|
50
|
+
return negotiate(value) if value.is_a?(Array)
|
|
51
|
+
|
|
52
|
+
DynamicImage::Format.find(value) ||
|
|
53
|
+
raise(ArgumentError, "unknown format: #{value.inspect}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def negotiate(formats)
|
|
57
|
+
DynamicImage::FormatNegotiator.new(record).negotiate(formats)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# An animated image keeps its own format if it can.
|
|
61
|
+
def fallback_formats
|
|
62
|
+
return DynamicImage::COMPATIBLE_FORMATS unless record.animated?
|
|
63
|
+
|
|
64
|
+
[stored_format_name, *DynamicImage::COMPATIBLE_FORMATS].compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stored_format_name
|
|
68
|
+
DynamicImage::Format.content_type(record.content_type)&.name
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DynamicImage
|
|
4
|
+
# = DynamicImage Picture
|
|
5
|
+
#
|
|
6
|
+
# Everything needed to render an image responsively: the candidate widths, the signed URLs behind them, and the
|
|
7
|
+
# fallback the <tt>img</tt> points at.
|
|
8
|
+
#
|
|
9
|
+
# Sizing is expressed as an optional ratio instead of a size.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# picture = DynamicImage::Picture.new(self, image, ratio: "16:9")
|
|
13
|
+
# picture.srcset # => "/images/… 420w, /images/… 590w, …"
|
|
14
|
+
# picture.src # => "/images/…/1200x675/…jpg"
|
|
15
|
+
#
|
|
16
|
+
# @see DynamicImage::Breakpoints for how the widths are chosen
|
|
17
|
+
# @see DynamicImage::Helper#dynamic_picture_tag
|
|
18
|
+
class Picture
|
|
19
|
+
# The options this class consumes. Anything else is passed on to the router.
|
|
20
|
+
OPTIONS = %i[ratio sizes breakpoints step fallback_width format uncropped].freeze
|
|
21
|
+
|
|
22
|
+
# @!attribute [r] record
|
|
23
|
+
# @return [DynamicImage::Model]
|
|
24
|
+
# @!attribute [r] ratio
|
|
25
|
+
# @return [Float, nil]
|
|
26
|
+
# @!attribute [r] sizes
|
|
27
|
+
# @return [String] the +sizes+ attribute
|
|
28
|
+
# @!attribute [r] breakpoints
|
|
29
|
+
# @return [DynamicImage::Breakpoints] the candidate widths
|
|
30
|
+
# @!attribute [r] fallback_width
|
|
31
|
+
# @return [Integer] the width asked for the fallback image
|
|
32
|
+
attr_reader :template, :record_or_array, :ratio, :sizes, :breakpoints, :fallback_width, :url_options
|
|
33
|
+
|
|
34
|
+
# @param template [ActionView::Base] the view context, for routing
|
|
35
|
+
# @param record_or_array [DynamicImage::Model, Array] the record, or an array of records for a nested route
|
|
36
|
+
# @param options [Hash]
|
|
37
|
+
# @option options [Numeric, Vector2d, String, nil] :ratio The aspect ratio to crop to, as a number, a vector, or a
|
|
38
|
+
# string like <tt>"16:9"</tt>. Implies cropping. Omit for the image's own.
|
|
39
|
+
# @option options [String] :sizes The +sizes+ attribute
|
|
40
|
+
# @option options [Range, Array<Integer>, Integer] :breakpoints The widths to offer, overriding
|
|
41
|
+
# {DynamicImage.default_breakpoints}
|
|
42
|
+
# @option options [Numeric] :step The step between breakpoints, overriding {DynamicImage.breakpoint_step}
|
|
43
|
+
# @option options [Integer] :fallback_width The width to ask for the fallback image, overriding
|
|
44
|
+
# {DynamicImage.picture_fallback_width}
|
|
45
|
+
# @option options [Symbol, Array<Symbol>] :format The format the candidates are rendered in. A symbol forces
|
|
46
|
+
# that format, an array is negotiated. Defaults to WebP, or the image's own format if it is animated.
|
|
47
|
+
# @option options [Boolean] :uncropped Size against the whole image, ignoring any pre-cropping. Defaults to
|
|
48
|
+
# whether the URL points at the +uncropped+ action.
|
|
49
|
+
#
|
|
50
|
+
# Any options supported by +polymorphic_url+ are also accepted, and passed on to the router.
|
|
51
|
+
def initialize(template, record_or_array, options = {})
|
|
52
|
+
options = options.symbolize_keys
|
|
53
|
+
@template = template
|
|
54
|
+
@record_or_array = Array(record_or_array)
|
|
55
|
+
@ratio = DynamicImage::Ratio.parse(options[:ratio])
|
|
56
|
+
@sizes = options[:sizes] || "100vw"
|
|
57
|
+
@requested_format = options[:format]
|
|
58
|
+
@fallback_width = fallback_width_from(options)
|
|
59
|
+
@breakpoints = breakpoints_from(options)
|
|
60
|
+
@url_options = options.except(*OPTIONS)
|
|
61
|
+
@uncropped = options.fetch(:uncropped) { url_options[:action].to_s == "uncropped" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns true if the image is cropped, which it is whenever a ratio is given.
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def crop?
|
|
68
|
+
!ratio.nil?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The widest the image can be rendered at: its own width, or the width of the largest crop matching the ratio.
|
|
72
|
+
#
|
|
73
|
+
# @return [Integer]
|
|
74
|
+
def available_width
|
|
75
|
+
@available_width ||= sizing.available_width(ratio)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The candidate widths, smallest first.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Integer>]
|
|
81
|
+
def widths
|
|
82
|
+
@widths ||= breakpoints.widths(available_width)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Every candidate, as the URL and the size it is actually rendered at.
|
|
86
|
+
#
|
|
87
|
+
# @return [Array<Hash>]
|
|
88
|
+
def variants
|
|
89
|
+
@variants ||= widths.map { |width| variant(width) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The +srcset+ attribute for the candidates, or nil if there are none. It belongs on the <tt>source</tt> when
|
|
93
|
+
# {#sources} has one, and on the <tt>img</tt> when it doesn't.
|
|
94
|
+
#
|
|
95
|
+
# @return [String, nil]
|
|
96
|
+
def srcset
|
|
97
|
+
return if variants.empty?
|
|
98
|
+
|
|
99
|
+
variants.map { |v| "#{v[:url]} #{v[:width]}w" }.join(", ")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The sources to render, as +type+ and +srcset+ pairs.
|
|
103
|
+
#
|
|
104
|
+
# Empty when the candidates are in the same format as the fallback; {#srcset} goes on the <tt>img</tt> instead.
|
|
105
|
+
# That is what happens to an animated image, which keeps its own format.
|
|
106
|
+
#
|
|
107
|
+
# @return [Array<Hash>]
|
|
108
|
+
def sources
|
|
109
|
+
candidates = srcset
|
|
110
|
+
return [] if candidates.nil? || format == fallback_format
|
|
111
|
+
|
|
112
|
+
[{ type:, srcset: candidates }]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The content type the candidates are rendered as, for the +type+ attribute.
|
|
116
|
+
#
|
|
117
|
+
# @return [String]
|
|
118
|
+
def type
|
|
119
|
+
format.content_type
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The format the fallback image is rendered in, negotiated from {DynamicImage::COMPATIBLE_FORMATS}.
|
|
123
|
+
#
|
|
124
|
+
# @return [DynamicImage::Format]
|
|
125
|
+
def fallback_format
|
|
126
|
+
format_policy.fallback
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The size asked for the fallback image, as a <tt>"{width}x{height}"</tt> string.
|
|
130
|
+
#
|
|
131
|
+
# @return [String]
|
|
132
|
+
def fallback_size
|
|
133
|
+
@fallback_size ||= size_for(fallback_width)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The size the fallback image is actually rendered at. Smaller than {#fallback_size} when the image is.
|
|
137
|
+
#
|
|
138
|
+
# @return [Vector2d]
|
|
139
|
+
def dimensions
|
|
140
|
+
@dimensions ||= sizing.fit(fallback_size, crop: crop?).floor
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The URL for the fallback image.
|
|
144
|
+
#
|
|
145
|
+
# @return [String]
|
|
146
|
+
def src
|
|
147
|
+
url_for(fallback_size, fallback_format)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The width of the fallback image.
|
|
151
|
+
#
|
|
152
|
+
# @return [Integer]
|
|
153
|
+
def width
|
|
154
|
+
dimensions.x.to_i
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The height of the fallback image.
|
|
158
|
+
#
|
|
159
|
+
# @return [Integer]
|
|
160
|
+
def height
|
|
161
|
+
dimensions.y.to_i
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def fallback_width_from(options)
|
|
167
|
+
(options[:fallback_width] || DynamicImage.picture_fallback_width).to_i
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def breakpoints_from(options)
|
|
171
|
+
DynamicImage::Breakpoints.new(options[:breakpoints], step: options[:step])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def record
|
|
175
|
+
record_or_array.last
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def sizing
|
|
179
|
+
@sizing ||= DynamicImage::ImageSizing.new(record, uncropped: @uncropped)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def variant(width)
|
|
183
|
+
requested = size_for(width)
|
|
184
|
+
size = sizing.fit(requested, crop: crop?).floor
|
|
185
|
+
# {#url_for} fits the size it is given. +size+ has been floored to whole pixels, so it is no longer exactly
|
|
186
|
+
# proportional and would fit smaller a second time. Pass the request instead.
|
|
187
|
+
{ url: url_for(requested, format),
|
|
188
|
+
width: size.x.to_i,
|
|
189
|
+
height: size.y.to_i }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def size_for(width)
|
|
193
|
+
return "#{width}x" unless ratio
|
|
194
|
+
|
|
195
|
+
"#{width}x#{(width / ratio).round}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def url_for(requested_size, image_format)
|
|
199
|
+
template.dynamic_image_path(record_or_array,
|
|
200
|
+
url_options.merge(
|
|
201
|
+
size: requested_size,
|
|
202
|
+
crop: crop?,
|
|
203
|
+
format: image_format.mime_type.to_sym
|
|
204
|
+
))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def format
|
|
208
|
+
format_policy.source
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def format_policy
|
|
212
|
+
@format_policy ||= FormatPolicy.new(record, @requested_format)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
module DynamicImage
|
|
4
4
|
# = DynamicImage Processed Image
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
6
|
+
# Crops, resizes and converts images, and reads back the processed data.
|
|
7
|
+
#
|
|
8
|
+
# Each processed size is stored as a variant, so subsequent calls for the same size are cheap.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# size = DynamicImage::ImageSizing.new(image).fit("800x800")
|
|
12
|
+
# data = DynamicImage::ProcessedImage.new(image, format: :jpg)
|
|
13
|
+
# .cropped_and_resized(size)
|
|
8
14
|
class ProcessedImage
|
|
15
|
+
# @return [DynamicImage::Model]
|
|
9
16
|
attr_reader :record
|
|
10
17
|
|
|
18
|
+
# @param record [DynamicImage::Model] the image to process
|
|
19
|
+
# @param options [Hash]
|
|
20
|
+
# @option options [Boolean] :uncropped Ignore any crop stored on the record
|
|
21
|
+
# @option options [Symbol, String] :format Format to convert to. Defaults to the format of the stored image.
|
|
11
22
|
def initialize(record, options = {})
|
|
12
23
|
@record = record
|
|
13
24
|
@uncropped = options[:uncropped] ? true : false
|
|
@@ -15,28 +26,40 @@ module DynamicImage
|
|
|
15
26
|
@format_name = "JPEG" if defined?(@format_name) && @format_name == "JPG"
|
|
16
27
|
end
|
|
17
28
|
|
|
18
|
-
# Crops and
|
|
29
|
+
# Crops, resizes and normalizes the image.
|
|
19
30
|
#
|
|
20
|
-
#
|
|
31
|
+
# @param size [Vector2d] the size to render, in pixels
|
|
32
|
+
# @return [String] the image data as a binary string
|
|
33
|
+
# @raise [DynamicImage::Errors::InvalidImage] if the record isn't a valid image, or if the stored data can't be
|
|
34
|
+
# processed
|
|
21
35
|
#
|
|
36
|
+
# @example
|
|
22
37
|
# processed = DynamicImage::ProcessedImage.new(image)
|
|
23
38
|
# image_data = processed.cropped_and_resized(Vector2d.new(200, 200))
|
|
24
|
-
#
|
|
25
|
-
# Returns a binary string.
|
|
26
39
|
def cropped_and_resized(size)
|
|
27
40
|
return crop_and_resize(size) unless record.persisted?
|
|
28
41
|
|
|
29
42
|
find_or_create_variant(size).data
|
|
30
43
|
end
|
|
31
44
|
|
|
32
|
-
#
|
|
45
|
+
# Returns the variant for the given size, creating it if it doesn't already exist.
|
|
46
|
+
#
|
|
47
|
+
# @param size [Vector2d] the size to render, in pixels
|
|
48
|
+
# @return [DynamicImage::Variant]
|
|
49
|
+
# @raise [DynamicImage::Errors::InvalidImage] if the record isn't a valid image, or if the stored data can't be
|
|
50
|
+
# processed
|
|
33
51
|
def find_or_create_variant(size)
|
|
34
52
|
find_variant(size) || create_variant(size)
|
|
35
53
|
rescue ActiveRecord::RecordNotUnique
|
|
36
54
|
find_variant(size)
|
|
37
55
|
end
|
|
38
56
|
|
|
39
|
-
#
|
|
57
|
+
# Returns the variant for the given size, if one exists.
|
|
58
|
+
#
|
|
59
|
+
# Variants whose data has gone missing from storage are destroyed and treated as absent.
|
|
60
|
+
#
|
|
61
|
+
# @param size [Vector2d] the size to look for, in pixels
|
|
62
|
+
# @return [DynamicImage::Variant, nil] the variant, if one exists
|
|
40
63
|
def find_variant(size)
|
|
41
64
|
return nil unless record.persisted?
|
|
42
65
|
|
|
@@ -51,8 +74,10 @@ module DynamicImage
|
|
|
51
74
|
end
|
|
52
75
|
end
|
|
53
76
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
77
|
+
# Same as {#cropped_and_resized}, but returns the variant record instead of its data.
|
|
78
|
+
#
|
|
79
|
+
# @param size [Vector2d] the size to render, in pixels
|
|
80
|
+
# @return [DynamicImage::Variant, nil] the variant, or nil if the record isn't persisted
|
|
56
81
|
def variant_for(size)
|
|
57
82
|
return nil unless record.persisted?
|
|
58
83
|
|
|
@@ -61,6 +86,9 @@ module DynamicImage
|
|
|
61
86
|
find_variant(size)
|
|
62
87
|
end
|
|
63
88
|
|
|
89
|
+
# The format the image is rendered in. Defaults to the format of the stored image.
|
|
90
|
+
#
|
|
91
|
+
# @return [DynamicImage::Format]
|
|
64
92
|
def format
|
|
65
93
|
DynamicImage::Format.find(@format_name) || record_format
|
|
66
94
|
end
|
|
@@ -73,12 +101,16 @@ module DynamicImage
|
|
|
73
101
|
# * Optimizes GIFs
|
|
74
102
|
# * Performs format conversion if the requested format is different
|
|
75
103
|
#
|
|
76
|
-
#
|
|
104
|
+
# @yield [image] an optional block to transform the image before it is written out
|
|
105
|
+
# @yieldparam image [DynamicImage::ImageProcessor] the processor
|
|
106
|
+
# @yieldreturn [DynamicImage::ImageProcessor] the transformed processor
|
|
107
|
+
# @return [String] the image data as a binary string
|
|
108
|
+
# @raise [DynamicImage::Errors::InvalidImage] if the record isn't a valid image, or if the stored data can't be
|
|
109
|
+
# processed
|
|
77
110
|
#
|
|
78
|
-
#
|
|
111
|
+
# @example
|
|
112
|
+
# processed = DynamicImage::ProcessedImage.new(image, format: :jpeg)
|
|
79
113
|
# jpg_data = processed.normalized
|
|
80
|
-
#
|
|
81
|
-
# Returns a binary string.
|
|
82
114
|
def normalized
|
|
83
115
|
require_valid_image!
|
|
84
116
|
|
|
@@ -87,6 +119,8 @@ module DynamicImage
|
|
|
87
119
|
image = yield(image) if block_given?
|
|
88
120
|
image.convert(format).read
|
|
89
121
|
end
|
|
122
|
+
rescue Vips::Error => e
|
|
123
|
+
raise DynamicImage::Errors::InvalidImage, e.message
|
|
90
124
|
end
|
|
91
125
|
|
|
92
126
|
private
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DynamicImage
|
|
4
|
+
# = DynamicImage Ratio
|
|
5
|
+
#
|
|
6
|
+
# Coerces an aspect ratio into a float. Strings, rationals, floats and vectors are all accepted.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# DynamicImage::Ratio.parse("16:9") # => 1.7777..
|
|
10
|
+
# DynamicImage::Ratio.parse("16x9") # => 1.7777..
|
|
11
|
+
# DynamicImage::Ratio.parse(Rational(16, 9)) # => 1.7777..
|
|
12
|
+
# DynamicImage::Ratio.parse(Vector2d(16, 9)) # => 1.7777..
|
|
13
|
+
module Ratio
|
|
14
|
+
class << self
|
|
15
|
+
# Returns the ratio as width divided by height.
|
|
16
|
+
#
|
|
17
|
+
# @param value [Numeric, Vector2d, String, nil] the ratio, as a number, a vector, or a string written with
|
|
18
|
+
# <tt>:</tt>, <tt>x</tt> or <tt>/</tt> between the two sides
|
|
19
|
+
# @return [Float, nil]
|
|
20
|
+
# @raise [ArgumentError] if the value isn't a usable ratio
|
|
21
|
+
def parse(value)
|
|
22
|
+
return if value.nil?
|
|
23
|
+
|
|
24
|
+
ratio = coerce(value)
|
|
25
|
+
unless ratio.is_a?(Float) && ratio.positive? && ratio.finite?
|
|
26
|
+
raise ArgumentError, "invalid ratio: #{value.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
ratio
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def coerce(value)
|
|
35
|
+
case value
|
|
36
|
+
when Numeric then value.to_f
|
|
37
|
+
when Vector2d then value.x.to_f / value.y
|
|
38
|
+
when String then value.split(%r{[:x/]}).map(&:to_f).reduce(:/)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
module DynamicImage
|
|
4
4
|
# = DynamicImage Routing
|
|
5
5
|
#
|
|
6
|
-
# Extends +ActionDispatch::Routing::Mapper+ and provides a shortcut for
|
|
7
|
-
#
|
|
6
|
+
# Extends +ActionDispatch::Routing::Mapper+ and provides a shortcut for defining routes for
|
|
7
|
+
# +DynamicImage::Controller+.
|
|
8
8
|
module Routing
|
|
9
|
-
# Declares an image resource.
|
|
9
|
+
# Declares an image resource, routing +show+ along with the +uncropped+, +original+ and +download+ member actions.
|
|
10
10
|
#
|
|
11
|
+
# The default path includes the digest and an optional size. Pass +:path+ to change it if the resource collides
|
|
12
|
+
# with a directory in +public+.
|
|
13
|
+
#
|
|
14
|
+
# @param resource_name [Symbol, String] the resource
|
|
15
|
+
# @param options [Hash] options passed to +resources+, merged over the defaults
|
|
16
|
+
# @return [void]
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
11
19
|
# image_resources :avatars
|
|
20
|
+
#
|
|
21
|
+
# @example Keeping clear of public/images
|
|
22
|
+
# image_resources :images, path: "dynamic_images/:digest(/:size)"
|
|
12
23
|
def image_resources(resource_name, options = {})
|
|
13
24
|
options = {
|
|
14
25
|
path: "#{resource_name}/:digest(/:size)",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DynamicImage
|
|
4
|
+
# = DynamicImage Schema
|
|
5
|
+
#
|
|
6
|
+
# The database schema {DynamicImage::Model} expects on the table holding the image.
|
|
7
|
+
#
|
|
8
|
+
# DynamicImage doesn't own that table. Unlike +dynamic_image_variants+, which has a fixed name and an engine
|
|
9
|
+
# migration, the image table is created in the application by the +dynamic_image:resource+ generator, under
|
|
10
|
+
# whatever name the application picked, and there may be more than one. This is the single definition of what it
|
|
11
|
+
# should contain.
|
|
12
|
+
#
|
|
13
|
+
# @see DynamicImage::Model
|
|
14
|
+
module Schema
|
|
15
|
+
# The expected columns, in the order they should appear in a migration. Each is described by its migration type
|
|
16
|
+
# and whether it accepts +NULL+.
|
|
17
|
+
#
|
|
18
|
+
# +frame_count+ and +alpha+ are nullable. NULL means the image predates them being recorded, which is not the
|
|
19
|
+
# same as knowing the image is a still or opaque.
|
|
20
|
+
#
|
|
21
|
+
# @return [Hash{Symbol => Hash}]
|
|
22
|
+
ATTRIBUTES = {
|
|
23
|
+
content_hash: { type: :string, null: false },
|
|
24
|
+
content_type: { type: :string, null: false },
|
|
25
|
+
content_length: { type: :integer, null: false },
|
|
26
|
+
filename: { type: :string, null: false },
|
|
27
|
+
colorspace: { type: :string, null: false },
|
|
28
|
+
real_width: { type: :integer, null: false },
|
|
29
|
+
real_height: { type: :integer, null: false },
|
|
30
|
+
frame_count: { type: :integer, null: true },
|
|
31
|
+
alpha: { type: :boolean, null: true },
|
|
32
|
+
crop_width: { type: :integer, null: true },
|
|
33
|
+
crop_height: { type: :integer, null: true },
|
|
34
|
+
crop_start_x: { type: :integer, null: true },
|
|
35
|
+
crop_start_y: { type: :integer, null: true },
|
|
36
|
+
crop_gravity_x: { type: :integer, null: true },
|
|
37
|
+
crop_gravity_y: { type: :integer, null: true }
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# The expected indexes.
|
|
41
|
+
#
|
|
42
|
+
# @return [Array<Hash>]
|
|
43
|
+
INDEXES = [
|
|
44
|
+
{ columns: %i[content_hash], unique: false }
|
|
45
|
+
].freeze
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/dynamic_image.rb
CHANGED
|
@@ -4,22 +4,82 @@ require "dis"
|
|
|
4
4
|
require "vector2d"
|
|
5
5
|
require "vips"
|
|
6
6
|
|
|
7
|
+
require "dynamic_image/backfill"
|
|
7
8
|
require "dynamic_image/belongs_to"
|
|
9
|
+
require "dynamic_image/breakpoints"
|
|
8
10
|
require "dynamic_image/controller"
|
|
9
11
|
require "dynamic_image/digest_verifier"
|
|
10
12
|
require "dynamic_image/engine"
|
|
11
13
|
require "dynamic_image/errors"
|
|
12
14
|
require "dynamic_image/format"
|
|
15
|
+
require "dynamic_image/format_negotiator"
|
|
13
16
|
require "dynamic_image/helper"
|
|
14
17
|
require "dynamic_image/image_processor"
|
|
15
18
|
require "dynamic_image/image_reader"
|
|
16
19
|
require "dynamic_image/image_sizing"
|
|
17
|
-
require "dynamic_image/jobs"
|
|
18
20
|
require "dynamic_image/metadata"
|
|
19
21
|
require "dynamic_image/model"
|
|
22
|
+
require "dynamic_image/picture"
|
|
23
|
+
require "dynamic_image/picture/format_policy"
|
|
20
24
|
require "dynamic_image/processed_image"
|
|
25
|
+
require "dynamic_image/ratio"
|
|
21
26
|
require "dynamic_image/routing"
|
|
27
|
+
require "dynamic_image/schema"
|
|
22
28
|
|
|
29
|
+
# A Rails engine for image uploads. Rather than creating a pre-defined set of images when a file is uploaded, it
|
|
30
|
+
# stores the original file and generates images on demand. It handles cropping, resizing, format and colorspace
|
|
31
|
+
# conversion, and stores each processed size as a variant.
|
|
32
|
+
#
|
|
33
|
+
# Include {DynamicImage::Model} in the model holding the image and {DynamicImage::Controller} in the controller
|
|
34
|
+
# serving it, then declare the routes with {DynamicImage::Routing#image_resources}. Views render images through the
|
|
35
|
+
# helpers in {DynamicImage::Helper}, which sign the URLs.
|
|
36
|
+
#
|
|
37
|
+
# Files are stored with Dis, and processing is done with libvips.
|
|
38
|
+
#
|
|
39
|
+
# @see DynamicImage::Model
|
|
40
|
+
# @see DynamicImage::Controller
|
|
41
|
+
# @see DynamicImage::Helper
|
|
42
|
+
# @see DynamicImage::ImageSizing
|
|
43
|
+
# @see DynamicImage::ProcessedImage
|
|
23
44
|
module DynamicImage
|
|
45
|
+
# The formats anything that renders images will understand. Fallback images in a <tt>picture</tt> are negotiated
|
|
46
|
+
# from this list, and it is the default for {DynamicImage.mailer_formats}.
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<Symbol>] the format names, most preferred first
|
|
49
|
+
COMPATIBLE_FORMATS = %i[jpeg png gif].freeze
|
|
50
|
+
|
|
51
|
+
# Verifies the HMAC digests embedded in image URLs. Set by the engine from the application's key generator, which
|
|
52
|
+
# derives it from <tt>secret_key_base</tt>.
|
|
53
|
+
#
|
|
54
|
+
# @return [DynamicImage::DigestVerifier]
|
|
24
55
|
cattr_accessor :digest_verifier
|
|
56
|
+
|
|
57
|
+
# The formats a view accepts when it doesn't pass <tt>format:</tt>. Also decides what
|
|
58
|
+
# {DynamicImage::Model#safe_content_type} considers safe.
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<Symbol>] the format names, most preferred first
|
|
61
|
+
mattr_accessor :default_formats, default: %i[jpeg png gif webp]
|
|
62
|
+
|
|
63
|
+
# The formats a mailer view accepts when it doesn't pass <tt>format:</tt>.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<Symbol>] the format names, most preferred first
|
|
66
|
+
mattr_accessor :mailer_formats, default: COMPATIBLE_FORMATS
|
|
67
|
+
|
|
68
|
+
# The breakpoints responsive images are rendered at. This can be a range, an array of explicit widths, or a single
|
|
69
|
+
# width.
|
|
70
|
+
#
|
|
71
|
+
# @return [Range, Array<Integer>, Integer]
|
|
72
|
+
# @see DynamicImage::Breakpoints
|
|
73
|
+
mattr_accessor :default_breakpoints, default: 320..3200
|
|
74
|
+
|
|
75
|
+
# The ratio between two breakpoints. Lower means a closer fit to what the browser needs, at the price of more
|
|
76
|
+
# variants to generate and store.
|
|
77
|
+
#
|
|
78
|
+
# @return [Float]
|
|
79
|
+
mattr_accessor :breakpoint_step, default: 1.4
|
|
80
|
+
|
|
81
|
+
# The width of the fallback image in a <tt>picture</tt> element.
|
|
82
|
+
#
|
|
83
|
+
# @return [Integer]
|
|
84
|
+
mattr_accessor :picture_fallback_width, default: 1200
|
|
25
85
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Creates a DynamicImage resource: a model including
|
|
3
|
+
DynamicImage::Model, a controller including DynamicImage::Controller,
|
|
4
|
+
a migration with the columns DynamicImage needs, and an
|
|
5
|
+
image_resources route.
|
|
6
|
+
|
|
7
|
+
Additional attributes are passed through to the migration, so a
|
|
8
|
+
resource can carry fields of its own.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
bin/rails generate dynamic_image:resource image
|
|
12
|
+
|
|
13
|
+
creates an Image model, an ImagesController and a migration.
|
|
14
|
+
|
|
15
|
+
bin/rails generate dynamic_image:resource photo caption:string
|
|
16
|
+
|
|
17
|
+
adds a caption column to the migration.
|