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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +393 -67
  3. data/app/models/dynamic_image/variant.rb +10 -0
  4. data/lib/dynamic_image/backfill.rb +85 -0
  5. data/lib/dynamic_image/belongs_to.rb +22 -0
  6. data/lib/dynamic_image/breakpoints.rb +93 -0
  7. data/lib/dynamic_image/controller.rb +51 -7
  8. data/lib/dynamic_image/digest_verifier.rb +20 -9
  9. data/lib/dynamic_image/engine.rb +3 -0
  10. data/lib/dynamic_image/errors.rb +30 -0
  11. data/lib/dynamic_image/format.rb +149 -8
  12. data/lib/dynamic_image/format_negotiator.rb +84 -0
  13. data/lib/dynamic_image/helper/formats.rb +57 -0
  14. data/lib/dynamic_image/helper/pictures.rb +124 -0
  15. data/lib/dynamic_image/helper.rb +100 -60
  16. data/lib/dynamic_image/image_processor/colors.rb +3 -7
  17. data/lib/dynamic_image/image_processor/frames.rb +10 -1
  18. data/lib/dynamic_image/image_processor/transform.rb +17 -2
  19. data/lib/dynamic_image/image_processor.rb +33 -6
  20. data/lib/dynamic_image/image_reader.rb +19 -1
  21. data/lib/dynamic_image/image_sizing.rb +51 -19
  22. data/lib/dynamic_image/metadata.rb +56 -5
  23. data/lib/dynamic_image/model/dimensions.rb +32 -13
  24. data/lib/dynamic_image/model/transformations.rb +47 -11
  25. data/lib/dynamic_image/model/validations.rb +15 -8
  26. data/lib/dynamic_image/model/variants.rb +6 -3
  27. data/lib/dynamic_image/model.rb +58 -34
  28. data/lib/dynamic_image/picture/format_policy.rb +72 -0
  29. data/lib/dynamic_image/picture.rb +215 -0
  30. data/lib/dynamic_image/processed_image.rb +48 -14
  31. data/lib/dynamic_image/ratio.rb +43 -0
  32. data/lib/dynamic_image/routing.rb +14 -3
  33. data/lib/dynamic_image/schema.rb +47 -0
  34. data/lib/dynamic_image/version.rb +1 -1
  35. data/lib/dynamic_image.rb +61 -1
  36. data/lib/rails/generators/dynamic_image/resource/USAGE +17 -0
  37. data/lib/rails/generators/dynamic_image/resource/resource_generator.rb +57 -21
  38. data/lib/rails/generators/dynamic_image/resource/templates/create_table_migration.rb.tt +32 -0
  39. data/lib/rails/generators/dynamic_image/upgrade/USAGE +17 -0
  40. data/lib/rails/generators/dynamic_image/upgrade/templates/upgrade_migration.rb.tt +10 -0
  41. data/lib/rails/generators/dynamic_image/upgrade/upgrade_generator.rb +111 -0
  42. data/lib/tasks/dynamic_image.rake +25 -0
  43. metadata +23 -12
  44. data/lib/dynamic_image/jobs/create_variant.rb +0 -22
  45. data/lib/dynamic_image/jobs.rb +0 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33ad9b707b455fded33a4b6860ce21d85e53ee88041ccb5f078beba0c850148b
4
- data.tar.gz: 79d19fbda9f9a90fa26dc421cf13fab00cb7d90251076bb27e6b316fddc4529a
3
+ metadata.gz: f7d6b0fd60017fe14ffc6a01f99519802232b8bba0f94b756c9026cdf57c6897
4
+ data.tar.gz: 2c0c56a8b4505684e883fd5b74985b69ac9e6f190a41477087fa4f62827eedb8
5
5
  SHA512:
6
- metadata.gz: 9eacc78569bb34efb9ae84381b1c96966e834cd9a9ff82968dcdf9aa690286a1192e3f13db1c899f4e31f93c15f293d673d9bf1fdf13e48a828e8ad0c8a27acf
7
- data.tar.gz: 1d47d899162e89321605adb632193c0a0cdace30b48b3feae2c61967e68b2fee92a66a009b7baf7c1e078b3a9ab09afc8529fc58f16105510cce80cdddf657cf
6
+ metadata.gz: 75c4599b900f98fa6c51adc140650b400ba19f7749e89e06d06b8ce580e61db32a3b7e3890cc35023c80b7b77da89400c1a74fa41e926e5ea0bd7cc516148ef6
7
+ data.tar.gz: 7a8573f7eb274a4f24842490ef296f50f750ba187f5a49b896b42fa0d4722fa255ed811151517c5d024094512e08c716855a4780050b22c04ef26ebbf6546174
data/README.md CHANGED
@@ -1,38 +1,26 @@
1
1
  [![Version](https://img.shields.io/gem/v/dynamic_image.svg?style=flat)](https://rubygems.org/gems/dynamic_image)
2
- ![Build](https://github.com/elektronaut/dynamic_image/workflows/Build/badge.svg)
2
+ [![Build](https://github.com/elektronaut/dynamic_image/actions/workflows/build.yml/badge.svg)](https://github.com/elektronaut/dynamic_image/actions/workflows/build.yml)
3
3
 
4
4
  # DynamicImage
5
5
 
6
- Need to handle image uploads in your Rails app?
7
- Give DynamicImage a try.
6
+ A Rails engine for image uploads.
8
7
 
9
8
  Rather than creating a pre-defined set of images when a file is
10
9
  uploaded, DynamicImage stores the original file and generates images
11
10
  on demand. It handles cropping, resizing, format and colorspace
12
11
  conversion.
13
12
 
14
- Supported formats at the moment are JPEG, PNG, GIF, BMP, WebP and TIFF.
15
- BMP, WebP and TIFF images will automatically be converted to JPG. CMYK
16
- images will be converted to RGB, and RGB images will be converted to the sRGB
17
- colorspace for consistent appearance in all browsers.
18
-
19
13
  DynamicImage is built on [Dis](https://github.com/elektronaut/dis)
20
14
  and [ruby-vips](https://github.com/libvips/ruby-vips).
21
15
 
22
- All URLs are signed with a HMAC to protect against denial of service
23
- and enumeration attacks.
24
-
25
- ## Requirements
26
-
27
- * Rails 5+
28
- * Ruby 2.7+
29
- * libvips 8.8+
30
-
31
- ## Documentation
16
+ ## Installation
32
17
 
33
- [Documentation is available on RubyDoc.info](http://rdoc.info/gems/dynamic_image)
18
+ DynamicImage requires [libvips](https://www.libvips.org), which is
19
+ available from most package managers.
34
20
 
35
- ## Installation
21
+ ```sh
22
+ brew install vips
23
+ ```
36
24
 
37
25
  Add the gem to your Gemfile and run `bundle install`.
38
26
 
@@ -40,102 +28,440 @@ Add the gem to your Gemfile and run `bundle install`.
40
28
  gem "dynamic_image", "~> 3.0"
41
29
  ```
42
30
 
43
- Run the `dis:install` generator to set up your storage.
31
+ Run the `dis:install` generator to set up your storage. Files are stored
32
+ in `db/dis` by default; edit the generated initializer to change that.
33
+ See the [Dis](https://github.com/elektronaut/dis) documentation for the
34
+ options.
44
35
 
45
36
  ```sh
46
37
  bin/rails generate dis:install
47
38
  ```
48
39
 
49
- You can edit the generated initializer to configure your storage, by default it
50
- will store files in `db/dis`. See the
51
- [Dis](https://github.com/elektronaut/dis) documentation for more
52
- information.
40
+ ## Getting started
53
41
 
54
- ## Creating your resource
42
+ ### Creating your resource
55
43
 
56
- Run the `dynamic_image:resource` generator to create your resource.
44
+ The `dynamic_image:resource` generator creates an `Image` model and a
45
+ controller, along with a migration and the necessary routes. The
46
+ migration creates the table for your resource, plus the table
47
+ DynamicImage uses to cache processed images.
57
48
 
58
49
  ```sh
59
50
  bin/rails generate dynamic_image:resource image
51
+ bin/rails db:migrate
60
52
  ```
61
53
 
62
- This will create an `Image` model and a controller, along with a migration and
63
- the necessary routes.
64
-
65
- Note that in this case, the route with collide with any static images stored
66
- in `public/images`. You can customize the path if you want in the route
67
- declaration.
54
+ The generated route collides with any static images stored in
55
+ `public/images`. Customize the path in the route declaration if that's a
56
+ problem.
68
57
 
69
58
  ```ruby
70
59
  image_resources :images, path: "dynamic_images/:digest(/:size)"
71
60
  ```
72
61
 
73
- ## Storing an image
62
+ <details>
63
+ <summary>Setting the model up by hand</summary>
64
+
65
+ Include `DynamicImage::Model` and provide these columns. The first four
66
+ are required by Dis, the rest hold the image metadata and crop.
67
+
68
+ ```ruby
69
+ create_table :images do |t|
70
+ t.string :content_hash, null: false
71
+ t.string :content_type, null: false
72
+ t.integer :content_length, null: false
73
+ t.string :filename, null: false
74
+ t.string :colorspace, null: false
75
+ t.integer :real_width, null: false
76
+ t.integer :real_height, null: false
77
+ t.integer :frame_count
78
+ t.boolean :alpha
79
+ t.integer :crop_width, :crop_height
80
+ t.integer :crop_start_x, :crop_start_y
81
+ t.integer :crop_gravity_x, :crop_gravity_y
82
+ t.timestamps
83
+ end
84
+
85
+ add_index :images, :content_hash
86
+ ```
87
+
88
+ The controller needs `DynamicImage::Controller` and a `model` method
89
+ telling it which class to serve.
90
+
91
+ ```ruby
92
+ class ImagesController < ApplicationController
93
+ include DynamicImage::Controller
94
+
95
+ private
96
+
97
+ def model
98
+ Image
99
+ end
100
+ end
101
+ ```
102
+
103
+ </details>
104
+
105
+ ### Storing an image
74
106
 
75
- To save an image, simply assign the file attribute to your uploaded file.
107
+ To save an image, assign the uploaded file to the `file` attribute.
76
108
 
77
109
  ```ruby
78
- image_params = params.require(:image).permit(:file)
79
- Image.create(image_params)
110
+ Image.create(params.expect(image: [:file]))
80
111
  ```
81
112
 
82
- ## Rendering images in your views
113
+ The image is parsed and validated when the record is saved. Dimensions,
114
+ colorspace and content type are read from the file itself, so anything
115
+ the client claims about the upload is ignored. If the file isn't a
116
+ readable image in a supported format, the record is invalid and an error
117
+ is added to `data`.
83
118
 
84
- You should use the provided helpers for displaying images, this will ensure
85
- that the generated URLs are properly signed and timestamped.
119
+ ### Associating images with other models
86
120
 
87
- To display the image at it's original size, use `dynamic_image_tag` without
88
- any options.
121
+ `belongs_to_image` works like `belongs_to` and takes the same options.
122
+ In addition to a record, it accepts an uploaded file directly and
123
+ creates the image record for you, so a file can be posted straight to
124
+ the parent model.
125
+
126
+ ```ruby
127
+ class User < ActiveRecord::Base
128
+ belongs_to_image :avatar, class_name: "Image"
129
+ validates_associated :avatar
130
+ end
131
+ ```
89
132
 
90
133
  ```erb
91
- <%= dynamic_image_tag(image) %>
134
+ <%= form_with(model: user) do |f| %>
135
+ <%= f.file_field :avatar %>
136
+ <% end %>
92
137
  ```
93
138
 
94
- To resize it, specify a max size. This will scale the image down to fit, but
95
- no cropping will occur.
139
+ ```ruby
140
+ User.create(params.expect(user: [:name, :avatar]))
141
+ ```
142
+
143
+ `validates_associated` is optional, but without it an invalid upload is
144
+ silently dropped when the parent is saved. Like `belongs_to`, the
145
+ association is required by default; pass `optional: true` if the image
146
+ is allowed to be missing.
147
+
148
+ ## Image URLs
149
+
150
+ `dynamic_image_path` and `dynamic_image_url` act pretty much like
151
+ regular URL helpers, and take the sizing options below.
96
152
 
97
153
  ```erb
98
- <%= dynamic_image_tag(image, size: "400x400") %>
154
+ <%= link_to "See image", dynamic_image_path(image, size: "400x400") %>
99
155
  ```
100
156
 
101
- Setting `crop: true` will crop the image to the exact size.
157
+ Every URL is signed and timestamped, so they can only be built
158
+ server-side. See [signed URLs](#signed-urls) for why.
159
+
160
+ ### Sizing options
161
+
162
+ Every helper that renders or links to a processed image takes the same
163
+ sizing options.
164
+
165
+ * `:size` - Desired image size, as `"{width}x{height}"`. The image is
166
+ scaled to fit within the size, preserving the aspect ratio. Omit
167
+ either dimension (`"400x"` or `"x400"`) for a fixed width or height.
168
+ * `:crop` - Crop the image to the exact size instead of fitting it.
169
+ Both dimensions are required.
170
+ * `:upscale` - By default, images are never scaled up, only down. Pass
171
+ `upscale: true` to allow the image to be scaled beyond its own size.
172
+ * `:format` - Render the image in a different format. See
173
+ [Formats](#formats).
174
+
175
+ ### Other versions of the image
176
+
177
+ Three variations are available. `uncropped` has `_tag`, `_path` and
178
+ `_url` helpers; `original` and `download` have `_path` and `_url`.
179
+ `uncropped` takes the sizing options; `original` and `download` serve
180
+ the stored file untouched and ignore them.
181
+
182
+ * `original_dynamic_image_path` links to the file exactly as it was
183
+ uploaded, with no processing at all.
184
+ * `download_dynamic_image_path` serves the original as an attachment,
185
+ prompting a download.
186
+ * `uncropped_dynamic_image_tag` renders the image with any pre-cropping
187
+ ignored.
188
+
189
+ ```erb
190
+ <%= link_to "Download", download_dynamic_image_path(image) %>
191
+ ```
192
+
193
+ ## Displaying images
194
+
195
+ `dynamic_image_tag` renders an `img`, taking the sizing options above
196
+ plus any HTML attributes.
102
197
 
103
198
  ```erb
199
+ <%= dynamic_image_tag(image) %>
200
+ <%= dynamic_image_tag(image, size: "400x400") %>
104
201
  <%= dynamic_image_tag(image, size: "400x400", crop: true) %>
105
202
  ```
106
203
 
107
- Omitting either dimension will render the image at an exact width or height.
204
+ ### Alt text
205
+
206
+ The `alt` attribute is resolved from `DynamicImage::Model#alt_text`. The
207
+ column isn't added by default, either create it yourself, or override the
208
+ method. Alternatively, pass the `alt` option to the helper.
108
209
 
109
210
  ```erb
110
- <%= dynamic_image_tag(image, size: "400x") %>
211
+ <%= dynamic_image_tag(image, alt: "A description") %>
212
+ <%= dynamic_image_tag(image, alt: "") %>
111
213
  ```
112
214
 
113
- `dynamic_image_path` and `dynamic_image_url` act pretty much like regular URL
114
- helpers.
215
+ ### Responsive images
216
+
217
+ `dynamic_picture_tag` renders a `picture` element covering a range of
218
+ widths using `srcset`.
115
219
 
116
220
  ```erb
117
- <%= link_to "See image", dynamic_image_path(image) %>
221
+ <%= dynamic_picture_tag(image, sizes: "50vw", alt: "A description") %>
118
222
  ```
119
223
 
120
- ## Caching
224
+ ```html
225
+ <picture>
226
+ <source type="image/webp"
227
+ srcset="/images/… 420w, /images/… 590w, /images/… 830w, …"
228
+ sizes="50vw">
229
+ <img src="/images/…/1200x800/….jpg" width="1200" height="800"
230
+ alt="A description">
231
+ </picture>
232
+ ```
121
233
 
122
- Generating images on the fly is expensive. This is less of a problem
123
- in development mode, as DynamicImage respects the If-Modified-Since
124
- header. In production, you should absolutely cache the results.
234
+ Note that there is no `size:` option here. Instead, pass a `ratio:`
235
+ when you want the image cropped. In addition to the `"16:9"` example
236
+ below, it also accepts rationals and floats.
125
237
 
126
- DynamicImage doesn't do any caching on it's own, but it is designed to
127
- play well with others. Here's a few options:
238
+ ```erb
239
+ <%= dynamic_picture_tag(image, ratio: "16:9", sizes: "50vw") %>
240
+ ```
128
241
 
129
- * [CloudFlare](https://www.cloudflare.com)
130
- * [Rack::Cache](http://rtomayko.github.io/rack-cache/)
131
- * [actionpack-page_caching](https://github.com/rails/actionpack-page_caching)
242
+ #### Image breakpoints
132
243
 
133
- It's perfectly safe to cache images indefinitely. The URL is
134
- timestamped, and will change if the object changes.
244
+ The `srcset` sizes are computed per image based on its own width,
245
+ stepping down geometrically in intervals, configured by `step:`.
246
+ The default configuration yields roughly 7 variants across the range,
247
+ depending on the original size.
135
248
 
136
- ## License
249
+ ```ruby
250
+ DynamicImage.default_breakpoints = 320..3200
251
+ DynamicImage.breakpoint_step = 1.4
252
+ DynamicImage.picture_fallback_width = 1200
253
+ ```
254
+
255
+ If you'd rather have fixed widths, change the range to an array:
256
+
257
+ ```ruby
258
+ DynamicImage.default_breakpoints = [400, 800, 1200]
259
+ ```
260
+
261
+ All three options can be overridden per call.
262
+
263
+ ```erb
264
+ <%= dynamic_picture_tag(image, sizes: "50vw",
265
+ breakpoints: 320..1600, step: 1.25) %>
266
+ <%= dynamic_picture_tag(image, sizes: "50vw", breakpoints: [400, 800, 1200]) %>
267
+ <%= dynamic_picture_tag(logo, sizes: "120px", breakpoints: 240) %>
268
+ ```
269
+
270
+ #### Media queries
271
+
272
+ `dynamic_picture_source_tag` renders a single `source`, so you can
273
+ compose a `picture` by hand when you want different crops depending on
274
+ media queries.
275
+
276
+ ```erb
277
+ <picture>
278
+ <%= dynamic_picture_source_tag(image, ratio: "21:9",
279
+ media: "(min-width: 1000px)") %>
280
+ <%= dynamic_picture_source_tag(image, ratio: "1:1") %>
281
+ <%= dynamic_image_tag(image, size: "1200x1200", crop: true, alt: "…") %>
282
+ </picture>
283
+ ```
284
+
285
+ ## Formats
137
286
 
138
- Copyright 2006-2016 Inge Jørgensen
287
+ Supported formats are JPEG, PNG, GIF, WebP, JPEG XL and TIFF.
288
+ BMP, HEIC and AVIF can also be uploaded, but aren't supported for output.
289
+
290
+ The preferred format lists are configurable, sorted most preferred format
291
+ first:
292
+
293
+ ```ruby
294
+ DynamicImage.default_formats = %i[jpeg png gif webp]
295
+ DynamicImage.mailer_formats = %i[jpeg png gif]
296
+ ```
297
+
298
+ Unless the source format matches the preferred formats, it will
299
+ be converted to the most appropriate format. For instance, a HEIC from
300
+ a phone will be served as either JPEG or PNG, depending on if it's
301
+ transparent or not.
302
+
303
+ Pass `format:` to override the defaults.
304
+
305
+ ```erb
306
+ <%= dynamic_image_tag(image, size: "400x400", format: %i[jpeg png gif]) %>
307
+ <%= dynamic_image_tag(image, size: "400x400", format: :jxl) %>
308
+ ```
309
+
310
+ For consistent appearance, all images are converted to the sRGB colorspace.
311
+ Any embedded color profiles will be taken into account when doing so.
312
+
313
+ ## Working with images
314
+
315
+ ### Cropping
316
+
317
+ Images can be pre-cropped by setting `crop_width`, `crop_height`,
318
+ `crop_start_x` and `crop_start_y`. The crop is applied to every rendered
319
+ version of the image, except the ones served by the `original` and
320
+ `uncropped` actions.
321
+
322
+ ```ruby
323
+ image.update(
324
+ crop_start_x: 15,
325
+ crop_start_y: 20,
326
+ crop_width: 300,
327
+ crop_height: 200
328
+ )
329
+ image.size # => Vector2d(300, 200)
330
+ image.real_size # => Vector2d(500, 400)
331
+ ```
332
+
333
+ By default, images are cropped from the center. Set `crop_gravity_x` and
334
+ `crop_gravity_y` to set a different focal point. When cropping,
335
+ DynamicImage will attempt to keep this pixel as close to the center as
336
+ possible without zooming in.
337
+
338
+ The crop gravity is relative to the original image, so that the crop size
339
+ can change without moving the focal point.
340
+
341
+ ```ruby
342
+ image.update(crop_gravity_x: 120, crop_gravity_y: 80)
343
+ ```
344
+
345
+ ### Transforming the stored image
346
+
347
+ Rendering never modifies the stored file, but `rotate` and `resize` do.
348
+ Both write a new file and update the stored dimensions, and neither
349
+ saves the record for you.
350
+
351
+ ```ruby
352
+ image.rotate(90)
353
+ image.resize(Vector2d.new(800, 800))
354
+ image.save
355
+ ```
356
+
357
+ `rotate` turns the image, taking the crop along with it. The angle must
358
+ be a multiple of 90.
359
+
360
+ `resize` scales the stored file down and replaces the original.
361
+
362
+ ### Outside of views
363
+
364
+ The helpers cover rendering in HTML, but the classes underneath are
365
+ public API and useful when you need dimensions or image data directly.
366
+
367
+ #### Calculating sizes
368
+
369
+ `DynamicImage::ImageSizing` can be used for size calculation.
370
+ It takes the same options as the helpers.
371
+
372
+ ```ruby
373
+ sizing = DynamicImage::ImageSizing.new(image) # a 1600x1000 image
374
+
375
+ sizing.fit("400x400") # => Vector2d(400.0, 250.0)
376
+ sizing.fit("400x400", crop: true) # => Vector2d(400.0, 400.0)
377
+ sizing.fit("2000x2000") # => Vector2d(1600.0, 1000.0)
378
+ ```
379
+
380
+ #### Processing images
381
+
382
+ `DynamicImage::ProcessedImage` returns processed image data as a binary
383
+ string.
384
+
385
+ ```ruby
386
+ size = DynamicImage::ImageSizing.new(image).fit("800x800")
387
+ data = DynamicImage::ProcessedImage.new(image, format: :jpg)
388
+ .cropped_and_resized(size)
389
+ ```
390
+
391
+ The result is stored as a variant, so that subsequent calls are cheap.
392
+
393
+ ## How it works
394
+
395
+ ### Signed URLs
396
+
397
+ All URLs are signed with a HMAC to protect against denial of service and
398
+ enumeration attacks.
399
+
400
+ The signing key is derived from your application's `secret_key_base`.
401
+ Take care if you rotate it, this will invalidate every image URL you
402
+ have generated. Plan for that if you rotate secrets.
403
+
404
+ ### Caching
405
+
406
+ Generating images on the fly is expensive, so each processed size is
407
+ stored as a variant, a separate record with its data in Dis, and reused
408
+ on subsequent requests. Variants are discarded automatically when the
409
+ image is replaced.
410
+
411
+ Responses are served with a far-future `Cache-Control` header and
412
+ respect `If-Modified-Since`, so they play well with an HTTP cache in
413
+ front — [CloudFlare](https://www.cloudflare.com),
414
+ [Rack::Cache](http://rtomayko.github.io/rack-cache/) or
415
+ [actionpack-page_caching](https://github.com/rails/actionpack-page_caching),
416
+ to name a few. It's perfectly safe to cache images indefinitely: the URL
417
+ is timestamped, and will change if the object changes.
418
+
419
+ ## Upgrading
420
+
421
+ DynamicImage can't migrate your image table for you: it's named whatever
422
+ you called it, and you may have several. When a release changes the
423
+ schema, you generate the migration. Most releases don't.
424
+
425
+ ### 3.1
426
+
427
+ Uploaded WebP is now served as WebP instead of being converted to JPEG,
428
+ so URLs and cached variants for WebP images change. Mailer views still
429
+ get JPEG, PNG or GIF. Set `DynamicImage.default_formats` back to
430
+ `%i[jpeg png gif]` to keep the old behaviour.
431
+
432
+ This release also adds `frame_count` and `alpha`, and an index on
433
+ `content_hash`. Run this once per image model.
434
+
435
+ ```sh
436
+ bin/rails generate dynamic_image:upgrade Image
437
+ bin/rails db:migrate
438
+ bin/rails dynamic_image:backfill MODELS=Image
439
+ ```
440
+
441
+ The backfill reads every image back from storage, so it takes a while on
442
+ a large library. It's safe to interrupt and re-run, and it leaves
443
+ `updated_at` alone, so existing URLs and cached variants stay valid.
444
+
445
+ Two things to watch on a large table. `add_index` locks the table while
446
+ it builds, so move it into its own migration with
447
+ `algorithm: :concurrently`. And if your table was created by an older
448
+ generator, its columns are all nullable and the generator will list the
449
+ ones that should be `NOT NULL`; correcting them is optional and left to
450
+ you, since `change_column_null` fails if any row holds a `NULL`.
451
+
452
+ ## Documentation
453
+
454
+ See the [generated documentation on RubyDoc.info](https://www.rubydoc.info/gems/dynamic_image),
455
+ and the [changelog](CHANGELOG.md) for release notes.
456
+
457
+ ## Contributing
458
+
459
+ Bug reports and pull requests are welcome on
460
+ [GitHub](https://github.com/elektronaut/dynamic_image). See
461
+ [CONTRIBUTING.md](CONTRIBUTING.md) for how to run the tests and how
462
+ commits are formatted, and note that this project ships with a
463
+ [code of conduct](CODE_OF_CONDUCT.md).
464
+
465
+ ## License
139
466
 
140
- DynamicImage is released under the
141
- [MIT License](http://www.opensource.org/licenses/MIT).
467
+ Released under the [MIT License](MIT-LICENSE).
@@ -1,6 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DynamicImage
4
+ # = DynamicImage Variant
5
+ #
6
+ # A cached rendering of an image at one size, crop and format. {DynamicImage::ProcessedImage} creates variants on
7
+ # demand and destroys them when the image they belong to changes or is destroyed, so there is rarely a reason to
8
+ # work with them directly.
9
+ #
10
+ # Data is stored in Dis under its own type, keeping it separate from the originals.
11
+ #
12
+ # @see DynamicImage::ProcessedImage
13
+ # @see DynamicImage::Model::Variants
4
14
  class Variant < ApplicationRecord
5
15
  include Dis::Model
6
16
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DynamicImage
4
+ # = DynamicImage Backfill
5
+ #
6
+ # Fills in the metadata columns for records stored before those columns existed. The values are read back from
7
+ # the stored file.
8
+ #
9
+ # Records are written with +update_columns+, so no callbacks run and +updated_at+ is left alone.
10
+ # {DynamicImage::Model#to_param} fingerprints on +updated_at+, and touching it would invalidate every image URL
11
+ # and every cached variant.
12
+ #
13
+ # DynamicImage::Backfill.new(Image).run
14
+ #
15
+ # @see DynamicImage::Schema
16
+ class Backfill
17
+ # The columns this fills in.
18
+ COLUMNS = %i[frame_count alpha].freeze
19
+
20
+ # @return [Class] the model being backfilled
21
+ attr_reader :model
22
+
23
+ # @return [Integer] records written
24
+ attr_reader :updated
25
+
26
+ # @return [Integer] records left alone, unreadable or missing
27
+ attr_reader :skipped
28
+
29
+ # @param model [Class] a model including {DynamicImage::Model}
30
+ def initialize(model)
31
+ @model = model
32
+ @updated = 0
33
+ @skipped = 0
34
+ end
35
+
36
+ # The records with a column still unset.
37
+ #
38
+ # @return [ActiveRecord::Relation] the pending records
39
+ def pending
40
+ COLUMNS.map { |column| model.where(column => nil) }.reduce(:or)
41
+ end
42
+
43
+ # Reads metadata for every pending record and fills the columns in.
44
+ #
45
+ # @yieldparam record [DynamicImage::Model] each record, after it has been processed
46
+ # @return [self]
47
+ # @raise [ArgumentError] if the table doesn't have the columns yet
48
+ def run
49
+ ensure_columns
50
+ pending.find_each do |record|
51
+ process(record)
52
+ yield(record) if block_given?
53
+ end
54
+ self
55
+ end
56
+
57
+ private
58
+
59
+ def ensure_columns
60
+ missing = COLUMNS.reject { |c| model.column_names.include?(c.to_s) }
61
+ return if missing.empty?
62
+
63
+ raise ArgumentError,
64
+ "#{model.table_name} has no #{missing.join(', ')} column. " \
65
+ "Run bin/rails generate dynamic_image:upgrade #{model.name}"
66
+ end
67
+
68
+ def process(record)
69
+ record.with_data_file { |path| apply(record, path) }
70
+ rescue Dis::Errors::NotFoundError
71
+ @skipped += 1
72
+ end
73
+
74
+ # Metadata reads lazily, so the values have to be resolved before the file goes out of scope. Written with
75
+ # update_columns so that no callbacks run.
76
+ def apply(record, path)
77
+ metadata = DynamicImage::Metadata.new(path)
78
+ return @skipped += 1 unless metadata.valid?
79
+
80
+ record.update_columns(frame_count: metadata.frame_count,
81
+ alpha: metadata.alpha?)
82
+ @updated += 1
83
+ end
84
+ end
85
+ end
@@ -3,10 +3,32 @@
3
3
  module DynamicImage
4
4
  # = DynamicImage Belongs To
5
5
  #
6
+ # Extends ActiveRecord with {ClassMethods#belongs_to_image}, an association that accepts an uploaded file in place
7
+ # of a record.
8
+ #
9
+ # The engine mixes this into +ActiveRecord::Base+, so it is available in any model.
6
10
  module BelongsTo
7
11
  extend ActiveSupport::Concern
8
12
 
9
13
  module ClassMethods
14
+ # Declares an association to an image. Behaves like +belongs_to+ and takes the same arguments, with one
15
+ # addition: assigning anything that isn't a {DynamicImage::Model} builds the associated record from it,
16
+ # treating it as an uploaded file. A file can therefore be posted straight to the parent model.
17
+ #
18
+ # The image is built, not saved, and is written when the parent is. Add +validates_associated+ if an invalid
19
+ # upload should invalidate the parent; without it the assignment is silently dropped on save.
20
+ #
21
+ # @param name [Symbol] the name of the association
22
+ # @param scope [Proc, nil] an optional scope, as +belongs_to+
23
+ # @return [void]
24
+ #
25
+ # @example
26
+ # class User < ActiveRecord::Base
27
+ # belongs_to_image :avatar, class_name: "Image"
28
+ # validates_associated :avatar
29
+ # end
30
+ #
31
+ # User.create(avatar: params[:file])
10
32
  def belongs_to_image(name, scope = nil, **)
11
33
  belongs_to(name, scope, **)
12
34