active_storage-blurhash 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b2017d1770807244bbd6a7c5478f4ecb59a5f13ee5b7eb5a086c2f09856973f0
4
+ data.tar.gz: 9f086097d1d55813b5c0edae2e221a9470af3260e442622ca294c05646fcf48e
5
+ SHA512:
6
+ metadata.gz: 0e47a24da41f183e833387d0d2d03eac0c22dfb032044a333e328be4f04d41c79d638966adc0af9672e30ec5bd464c08e44a595d4e9d45b322891d2f20723578
7
+ data.tar.gz: 4700ccb58f8bc1e8e0b93e8f7207a09e3ebac00e0773fe8675fa2f88489fe2c4c2ff852c22a68b41670cb7db30559d1b056b3b19ef864cd11d7cfd33102e79d0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Julian Rubisch
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # ActiveStorage::Blurhash
2
+
3
+ A [blurhash](https://blurha.sh/) integration for images stored in ActiveStorage.
4
+
5
+ ## Motivation
6
+ Elimination of layout shift and speeding up First/Largest Contentful Paint are among the primary goals for improving Core Web Vitals. For both scenarios, lazy loading images while displaying a temporary Blurhash before swapping it out for the actual image is a great way to enhance the loading experience and perceived performance.
7
+
8
+ ## Usage
9
+
10
+ Suppose we have a simple `Person` model containing an `avatar` attachment:
11
+
12
+ ```rb
13
+ class Person < ApplicationRecord
14
+ has_one_attached :avatar
15
+ end
16
+ ```
17
+
18
+ Simply swap out `image_tag` for `blurhash_image_tag` when displaying it:
19
+
20
+ ```erb
21
+ <%= blurhash_image_tag person.avatar %>
22
+ ```
23
+
24
+ This will create a wrapper `<div>` containing a `<canvas>` that is going to be painted with the respective blurhash.
25
+
26
+ Make sure to run the install generator and backfill any existing attachments (see below). New attachments should be automatically analyzed to include the blurhash metadata.
27
+
28
+
29
+ ## Installation
30
+ Add this line to your application's Gemfile:
31
+
32
+ ```ruby
33
+ gem "active_storage-blurhash"
34
+ ```
35
+
36
+ And then execute:
37
+ ```bash
38
+ $ bundle
39
+ ```
40
+
41
+
42
+ Install the JavaScript packages and blurhash snippet:
43
+
44
+ ```bash
45
+ $ bin/rails g active_storage:blurhash:install
46
+ ```
47
+
48
+ ## Backfilling
49
+
50
+ Chances are you already have a large assortment of ActiveStorage attachments. In this case, we can help you out with a backfill rake task that re-analyzes all existing image blobs:
51
+
52
+ ```bash
53
+ $ bin/rails active_storage_blurhash:backfill
54
+ ```
55
+
56
+ If you want to throttle it, you can change the batch size using the `BATCH_SIZE` environment variable (default is 1000):
57
+
58
+ ```bash
59
+ $ BATCH_SIZE=50 bin/rails active_storage_blurhash:backfill
60
+ ```
61
+
62
+ Note that for each blob an `AnalyzeJob` will be appended to your job processor queue.
63
+
64
+ ## License
65
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlurhashImageHelper
4
+ def blurhash_image_tag(source, options = {})
5
+ case source
6
+ when String
7
+ # if a URL is passed, we have to manually re-hydrate the blob from it
8
+ path_parameters = Rails.application.routes.recognize_path(source)
9
+ blob = ActiveStorage::Blob.find_signed!(path_parameters[:signed_blob_id] || path_parameters[:signed_id])
10
+ when ActiveStorage::Blob
11
+ blob = source
12
+ when ActiveStorage::Attached::One
13
+ blob = source.blob
14
+ when ActiveStorage::VariantWithRecord
15
+ blob = source.blob
16
+ # dimensions of the transformation need not represent the actual aspect ratio, like in `resize_to_fit`
17
+ # size = source.variation.transformations[:resize]
18
+ end
19
+
20
+ blurhash = blob.metadata["blurhash"]
21
+ size ||= "#{blob.metadata["width"]}x#{blob.metadata["height"]}"
22
+
23
+ if !!blurhash
24
+ options[:loading] = "lazy"
25
+ options[:size] = size
26
+
27
+ wrapper_class = options.delete(:wrapper_class)
28
+ canvas_class = options.delete(:canvas_class)
29
+ tag.div class: wrapper_class, data: {blurhash: blurhash}, style: "position: relative" do
30
+ image_tag(source, options) + tag.canvas(style: "position: absolute; inset: 0; transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms;", class: canvas_class)
31
+ end
32
+ else
33
+ image_tag(source, options)
34
+ end
35
+ rescue ActionController::RoutingError
36
+ image_tag(source, options)
37
+ end
38
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Rails.application.routes.draw do
2
+ end
@@ -0,0 +1,16 @@
1
+ require "active_storage/blurhash/analyzing"
2
+ require "active_storage/blurhash/thumbnail/image_magick"
3
+
4
+ module ActiveStorage
5
+ module Blurhash
6
+ module Analyzer
7
+ class ImageMagick < ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick
8
+ include ActiveStorage::Blurhash::Analyzing
9
+
10
+ protected
11
+
12
+ def processor = "ImageMagick"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ require "active_storage/blurhash/analyzing"
2
+ require "active_storage/blurhash/thumbnail/vips"
3
+
4
+ module ActiveStorage
5
+ module Blurhash
6
+ module Analyzer
7
+ class Vips < ActiveStorage::Analyzer::ImageAnalyzer::Vips
8
+ include ActiveStorage::Blurhash::Analyzing
9
+
10
+ protected
11
+
12
+ def processor = "Vips"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,32 @@
1
+ require "blurhash"
2
+
3
+ module ActiveStorage
4
+ module Blurhash
5
+ module Analyzing
6
+ attr_accessor :thumbnail
7
+
8
+ def metadata
9
+ # we could also re-implement #metadata, so that the image is only read once, but it's much less DRY
10
+ read_image do |image|
11
+ build_thumbnail(image)
12
+ super.merge blurhash
13
+ end
14
+ end
15
+
16
+ def blurhash
17
+ {
18
+ blurhash: ::Blurhash.encode(
19
+ thumbnail.width,
20
+ thumbnail.height,
21
+ thumbnail.pixels
22
+ )
23
+ }
24
+ end
25
+
26
+ def build_thumbnail(image)
27
+ # we scale down the image for faster blurhash processing
28
+ @thumbnail ||= "ActiveStorage::Blurhash::Thumbnail::#{processor}".constantize.new(image)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,13 @@
1
+ require "active_storage/blurhash/analyzer/image_magick"
2
+ require "active_storage/blurhash/analyzer/vips"
3
+
4
+ module ActiveStorage
5
+ module Blurhash
6
+ class Engine < ::Rails::Engine
7
+ initializer "active_storage-blurhash.analyze" do
8
+ Rails.application.config.active_storage.analyzers.prepend ActiveStorage::Blurhash::Analyzer::ImageMagick
9
+ Rails.application.config.active_storage.analyzers.prepend ActiveStorage::Blurhash::Analyzer::Vips
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ module ActiveStorage
2
+ module Blurhash
3
+ module Thumbnail
4
+ class ImageMagick
5
+ delegate_missing_to :@thumbnail
6
+
7
+ def initialize(image)
8
+ @thumbnail = MiniMagick::Image.open(
9
+ ::ImageProcessing::MiniMagick.source(image.path).resize_to_limit(200, 200).call.path
10
+ )
11
+ end
12
+
13
+ def pixels
14
+ @thumbnail.get_pixels.flatten
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ module ActiveStorage
2
+ module Blurhash
3
+ module Thumbnail
4
+ class Vips
5
+ delegate_missing_to :@thumbnail
6
+
7
+ def initialize(image)
8
+ @thumbnail = ::Vips::Image.new_from_file(
9
+ ::ImageProcessing::Vips.source(image.filename).resize_to_limit(200, 200).call.path
10
+ )
11
+ end
12
+
13
+ def pixels
14
+ @thumbnail.write_to_memory.unpack("C*")
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ module ActiveStorage
2
+ module Blurhash
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ require "active_storage/blurhash/version"
2
+ require "active_storage/blurhash/engine"
3
+
4
+ module ActiveStorage
5
+ module Blurhash
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ Description:
2
+ This generator installs/pins the `blurhash` NPM package and copies over the necessary JavaScript boilerplate.
3
+
4
+ Example:
5
+ bin/rails generate active_storage:blurhash:install
6
+
7
+ This will create:
8
+ app/javascript/blurhash/index.js
@@ -0,0 +1,21 @@
1
+ class ActiveStorage::Blurhash::InstallGenerator < Rails::Generators::Base
2
+ source_root File.expand_path("templates", __dir__)
3
+
4
+ def install_javascript_deps
5
+ if File.exist? Rails.root.join("config", "importmap.rb")
6
+ say "Pinning blurhash"
7
+ run "bin/importmap pin blurhash"
8
+ else
9
+ say "Installing blurhash"
10
+ run "yarn add blurhash"
11
+ end
12
+ end
13
+
14
+ def copy_application_javascript
15
+ directory "javascript", "app/javascript/blurhash"
16
+ end
17
+
18
+ def append_to_main_javascript_entrypoint
19
+ append_to_file "app/javascript/application.js", "import \"./blurhash\";\n"
20
+ end
21
+ end
@@ -0,0 +1,107 @@
1
+ import { decode } from "blurhash";
2
+
3
+ window.ActiveStorageBlurhash = {
4
+ observer: new MutationObserver((mutationList, observer) => {
5
+ mutationList.forEach(({ type, target, addedNodes, attributeName }) => {
6
+ switch (type) {
7
+ case "childList":
8
+ if (addedNodes.length === 0) return;
9
+
10
+ Array.from(addedNodes)
11
+ .filter((node) => {
12
+ try {
13
+ return "blurhash" in node.dataset;
14
+ } catch (e) {
15
+ return false;
16
+ }
17
+ })
18
+ .forEach((node) => {
19
+ window.ActiveStorageBlurhash.renderAndLoad(node);
20
+ });
21
+ break;
22
+ case "attributes":
23
+ window.ActiveStorageBlurhash.renderAndLoad(target);
24
+ break;
25
+ }
26
+ });
27
+ }),
28
+ renderAndLoad(wrapper) {
29
+ const image = wrapper.querySelector("img");
30
+
31
+ // the image might already be completely loaded. In this case we need to do nothing
32
+ if (image.complete) return;
33
+
34
+ // if the image comes in with empty dimensions, we can't assign canvas data
35
+ if (image.width === 0 || image.height === 0) return;
36
+
37
+ const width = image.width;
38
+ const height = image.height;
39
+
40
+ const canvas = wrapper.querySelector("canvas");
41
+
42
+ canvas.width = width;
43
+ canvas.height = height;
44
+
45
+ const pixels = decode(wrapper.dataset.blurhash, width, height);
46
+ const ctx = canvas.getContext("2d");
47
+ const imageData = ctx.createImageData(width, height);
48
+ imageData.data.set(pixels);
49
+ ctx.putImageData(imageData, 0, 0);
50
+
51
+ sessionStorage.setItem(
52
+ `active-storage-blurhash-${wrapper.dataset.blurhash}`,
53
+ canvas.toDataURL(),
54
+ );
55
+
56
+ const swap = () => {
57
+ canvas.style.opacity = "0";
58
+ };
59
+
60
+ if (image.complete) {
61
+ // the image might already have been loaded
62
+ swap();
63
+ } else {
64
+ // else we need to wait for it to load
65
+ image.onload = swap;
66
+ }
67
+ },
68
+ restoreCanvases() {
69
+ const inNamespace = ([key, _payload]) =>
70
+ key.startsWith("active-storage-blurhash-");
71
+ Object.entries(sessionStorage)
72
+ .filter(inNamespace)
73
+ .forEach(([key, _payload]) => {
74
+ const match = /^active-storage-blurhash-(.*)/.exec(key);
75
+ const targetElement = document.querySelector(
76
+ `[data-blurhash="${match[1]}"]`,
77
+ );
78
+
79
+ if (targetElement) {
80
+ const canvas = targetElement.querySelector("canvas");
81
+ canvas.style.opacity = "100%";
82
+
83
+ // right now we don't do anything with the payload, since setting the opacity to 100% apparently correctly prepares the elements for caching
84
+ sessionStorage.removeItem(key);
85
+ }
86
+ });
87
+ },
88
+ };
89
+
90
+ document.addEventListener("turbo:load", () => {
91
+ document.querySelectorAll("div[data-blurhash]").forEach((wrapper) => {
92
+ window.ActiveStorageBlurhash.renderAndLoad(wrapper);
93
+ });
94
+
95
+ window.ActiveStorageBlurhash.observer.disconnect();
96
+ window.ActiveStorageBlurhash.observer.observe(document.body, {
97
+ subtree: true,
98
+ childList: true,
99
+ attributes: true,
100
+ attributeFilter: ["data-blurhash"],
101
+ });
102
+ });
103
+
104
+ document.addEventListener(
105
+ "turbo:before-cache",
106
+ window.ActiveStorageBlurhash.restoreCanvases,
107
+ );
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :active_storage_blurhash do
4
+ desc "Backfill blurhash metadata for existing ActiveStorage image attachments"
5
+ task backfill: :environment do
6
+ batch_size = ENV["BATCH_SIZE"]&.to_i || 1000
7
+
8
+ ActiveStorage::Attachment
9
+ .joins(:blob)
10
+ .where("content_type LIKE ?", "image/%")
11
+ .find_each(batch_size: batch_size) do |attachment|
12
+ attachment.analyze_later
13
+ end
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,189 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_storage-blurhash
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julian Rubisch
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-08-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activestorage
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.1.3.4
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 7.1.3.4
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 7.1.3.4
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 7.1.3.4
41
+ - !ruby/object:Gem::Dependency
42
+ name: blurhash
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.1.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.1.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: image_processing
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 1.0.0
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 1.0.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 7.1.3.4
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 7.1.3.4
83
+ - !ruby/object:Gem::Dependency
84
+ name: mini_magick
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: nokogiri
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: ruby-vips
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: standard
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description: Use blurhashes to lazy load ActiveStorage images
140
+ email:
141
+ - julian@julianrubisch.at
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - MIT-LICENSE
147
+ - README.md
148
+ - Rakefile
149
+ - app/assets/config/active_storage_blurhash_manifest.js
150
+ - app/helpers/blurhash_image_helper.rb
151
+ - config/routes.rb
152
+ - lib/active_storage/blurhash.rb
153
+ - lib/active_storage/blurhash/analyzer/image_magick.rb
154
+ - lib/active_storage/blurhash/analyzer/vips.rb
155
+ - lib/active_storage/blurhash/analyzing.rb
156
+ - lib/active_storage/blurhash/engine.rb
157
+ - lib/active_storage/blurhash/thumbnail/image_magick.rb
158
+ - lib/active_storage/blurhash/thumbnail/vips.rb
159
+ - lib/active_storage/blurhash/version.rb
160
+ - lib/generators/active_storage/blurhash/install/USAGE
161
+ - lib/generators/active_storage/blurhash/install/install_generator.rb
162
+ - lib/generators/active_storage/blurhash/install/templates/javascript/index.js
163
+ - lib/tasks/active_storage/blurhash_tasks.rake
164
+ homepage: https://github.com/avo-hq/active_storage-blurhash
165
+ licenses:
166
+ - MIT
167
+ metadata:
168
+ homepage_uri: https://github.com/avo-hq/active_storage-blurhash
169
+ source_code_uri: https://github.com/avo-hq/active_storage-blurhash
170
+ post_install_message:
171
+ rdoc_options: []
172
+ require_paths:
173
+ - lib
174
+ required_ruby_version: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - ">="
177
+ - !ruby/object:Gem::Version
178
+ version: '0'
179
+ required_rubygems_version: !ruby/object:Gem::Requirement
180
+ requirements:
181
+ - - ">="
182
+ - !ruby/object:Gem::Version
183
+ version: '0'
184
+ requirements: []
185
+ rubygems_version: 3.4.10
186
+ signing_key:
187
+ specification_version: 4
188
+ summary: Use blurhashes to lazy load ActiveStorage images
189
+ test_files: []