active_storage-async_variants 0.6.0 → 0.7.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/CHANGELOG.md +7 -0
- data/CLAUDE.md +4 -4
- data/README.md +14 -36
- data/app/assets/javascripts/progress-bar.js +13 -1
- data/app/assets/stylesheets/progress.css +12 -7
- data/app/views/active_storage/async_variants/states/_failed.html.erb +7 -6
- data/app/views/active_storage/async_variants/states/_processing.html.erb +1 -2
- data/features/state_rendering.feature +1 -0
- data/features/step_definitions/dummy_steps.rb +4 -0
- data/lib/active_storage/async_variants/attachment_extension.rb +1 -1
- data/lib/active_storage/async_variants/helper.rb +19 -0
- data/lib/active_storage/async_variants/preview_extension.rb +9 -25
- data/lib/active_storage/async_variants/reflection_extension.rb +1 -1
- data/lib/active_storage/async_variants/registry.rb +1 -1
- data/lib/active_storage/async_variants/variant_record_extension.rb +2 -2
- data/lib/active_storage/async_variants/variant_with_record_extension.rb +7 -18
- data/lib/active_storage/async_variants/variation_extension.rb +2 -2
- data/lib/active_storage/async_variants/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 259c11526314988d60e78e9e176dc16105c6aeb578b1774693a666fc1e277a21
|
|
4
|
+
data.tar.gz: 92fc3546ed708ed709ad4fedc2d7e6258d765719fad75571844671a902f1b485
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebfe0839de2603c91a5705f3942b30b3db7685d20ce5d3d2425f98472a191d559ac9103bab5a59972e09f18385b8829283fe0323f2de4810e8619cb57e028ab2
|
|
7
|
+
data.tar.gz: 165f5796f098a3f40194a9d893ba07b98bd7b3a7ac8601fda744f5dcb1e4a98c0d69e1514e788f612ea8fa1dbf4bca6c44cbb7ee5ef072fcf5fa13fe03ce5b56
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [0.7.0]
|
|
2
|
+
|
|
3
|
+
- **Breaking:** A variant now opts into async processing with `async: true` instead of `processing:`. The `processing:` and `failed:` placeholder options are removed entirely — there is nothing to configure. A variant's `.url` serves the original while pending/processing/failed, and the processed variant once ready.
|
|
4
|
+
- The `async:`-helper UI no longer fetches a placeholder through the representations path on every poll. The processing/failed states render a zero-network sized box with the progress bar floating over it.
|
|
5
|
+
- The failed state now renders the progress bar in its error state (a static red ring with an X, via `@botandrose/progress-bar`'s `error` attribute) rather than a configurable failed SVG. The retry affordance is unchanged.
|
|
6
|
+
- Migration note: replace `processing: <anything>` with `async: true` on each async variant and drop any `failed:` option. Any `:original`/`:blank`/String/Proc placeholder values no longer have an effect.
|
|
7
|
+
|
|
1
8
|
## [0.6.0]
|
|
2
9
|
|
|
3
10
|
- External transforms can now report progress: a `progress` callback records the percent complete and a heartbeat, readable via `#progress` / `#progress_known?` on variants and previews.
|
data/CLAUDE.md
CHANGED
|
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
4
4
|
|
|
5
5
|
## What This Is
|
|
6
6
|
|
|
7
|
-
A Rails engine gem that extends Active Storage with async-safe variant processing. Solves the problem where slow transformations (e.g., video transcoding) block requests or fail silently. The `
|
|
7
|
+
A Rails engine gem that extends Active Storage with async-safe variant processing. Solves the problem where slow transformations (e.g., video transcoding) block requests or fail silently. The `async: true` option on a variant definition opts it into async processing.
|
|
8
8
|
|
|
9
9
|
## Commands
|
|
10
10
|
|
|
@@ -19,9 +19,9 @@ bundle exec rspec spec/active_storage/async_variants_spec.rb -e "description" #
|
|
|
19
19
|
|
|
20
20
|
The gem works by prepending extension modules onto Active Storage classes:
|
|
21
21
|
|
|
22
|
-
- **`VariationExtension`** → `ActiveStorage::Variation` — extracts async options (`
|
|
23
|
-
- **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for variants with `
|
|
24
|
-
- **`VariantWithRecordExtension`** → `ActiveStorage::VariantWithRecord` — overrides URL generation to serve the
|
|
22
|
+
- **`VariationExtension`** → `ActiveStorage::Variation` — extracts async options (`async:`, `transformer:`) from variant config before passing the rest to standard Active Storage
|
|
23
|
+
- **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for variants with `async: true`
|
|
24
|
+
- **`VariantWithRecordExtension`** → `ActiveStorage::VariantWithRecord` — overrides URL generation to serve the original blob while not ready; adds state query methods (`processed?`, `processing?`, `pending?`, `failed?`)
|
|
25
25
|
- **`ProcessJob`** — background job that determines transformer type (inline vs external) and processes accordingly
|
|
26
26
|
|
|
27
27
|
### Transformer Types
|
data/README.md
CHANGED
|
@@ -19,7 +19,7 @@ bin/rails db:migrate
|
|
|
19
19
|
|
|
20
20
|
## Usage
|
|
21
21
|
|
|
22
|
-
Add `
|
|
22
|
+
Add `async: true` to any named variant to opt into the async pipeline:
|
|
23
23
|
|
|
24
24
|
```ruby
|
|
25
25
|
class User < ApplicationRecord
|
|
@@ -28,12 +28,12 @@ class User < ApplicationRecord
|
|
|
28
28
|
transformer: VideoTranscoder,
|
|
29
29
|
codec: "vp9",
|
|
30
30
|
resolution: "720p",
|
|
31
|
-
|
|
31
|
+
async: true
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
The presence of `
|
|
36
|
+
The presence of `async: true` is what opts a variant into async processing. Without it, variants behave exactly as they do in standard Active Storage. The `transformer:` option is independent -- you can use a custom transformer synchronously, or use the default transformer asynchronously:
|
|
37
37
|
|
|
38
38
|
```ruby
|
|
39
39
|
has_one_attached :video do |attachable|
|
|
@@ -41,12 +41,12 @@ has_one_attached :video do |attachable|
|
|
|
41
41
|
attachable.variant :web,
|
|
42
42
|
transformer: VideoTranscoder,
|
|
43
43
|
codec: "vp9",
|
|
44
|
-
|
|
44
|
+
async: true
|
|
45
45
|
|
|
46
46
|
# Async with default transformer (large image resize that's too slow for inline)
|
|
47
47
|
attachable.variant :thumbnail,
|
|
48
48
|
resize_to_limit: [200, 200],
|
|
49
|
-
|
|
49
|
+
async: true
|
|
50
50
|
|
|
51
51
|
# Sync with custom transformer (fast custom processing, no opt-in needed)
|
|
52
52
|
attachable.variant :watermarked,
|
|
@@ -60,23 +60,23 @@ In views, use the same Active Storage helpers:
|
|
|
60
60
|
<%= video_tag user.video.variant(:web).url %>
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
While the variant is still processing (or has failed), this serves the original video. Once processing completes, it serves the transcoded variant.
|
|
64
64
|
|
|
65
65
|
## `image_tag` / `video_tag` with `async:` and `direct:`
|
|
66
66
|
|
|
67
|
-
For a polish layer that
|
|
67
|
+
For a polish layer that shows a progress bar while a variant is being processed, polls for completion in the background, and optionally serves the finished variant straight from your CDN, the gem adds two options to `image_tag` and `video_tag`:
|
|
68
68
|
|
|
69
69
|
```erb
|
|
70
|
-
<%# Variant URL with the async client wired up:
|
|
70
|
+
<%# Variant URL with the async client wired up: a progress bar while pending,
|
|
71
71
|
polls for completion, swaps to the real image when ready. %>
|
|
72
72
|
<%= image_tag user.avatar.variant(:web), async: true %>
|
|
73
73
|
|
|
74
74
|
<%# When the variant is ready, render its direct CDN/S3 URL instead of
|
|
75
75
|
routing through Rails. While pending/failed, falls back to the
|
|
76
|
-
Rails representation URL (which serves the
|
|
76
|
+
Rails representation URL (which serves the original). %>
|
|
77
77
|
<%= image_tag user.avatar.variant(:web), direct: true %>
|
|
78
78
|
|
|
79
|
-
<%# Both together: direct URL once processed,
|
|
79
|
+
<%# Both together: direct URL once processed, progress bar while pending,
|
|
80
80
|
polling for completion. %>
|
|
81
81
|
<%= image_tag user.avatar.variant(:web), async: true, direct: true %>
|
|
82
82
|
|
|
@@ -227,31 +227,9 @@ variant.failed? # => true if permanently failed
|
|
|
227
227
|
variant.error # => error message string, or nil
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
##
|
|
230
|
+
## Placeholders
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
```ruby
|
|
235
|
-
# Serve the original unprocessed file while processing
|
|
236
|
-
attachable.variant :web, processing: :original
|
|
237
|
-
|
|
238
|
-
# Return nil -- let the view handle it
|
|
239
|
-
attachable.variant :web, processing: :blank
|
|
240
|
-
|
|
241
|
-
# Static URL while processing
|
|
242
|
-
attachable.variant :web, processing: "/placeholders/processing.svg"
|
|
243
|
-
|
|
244
|
-
# Dynamic placeholder via Proc
|
|
245
|
-
attachable.variant :web,
|
|
246
|
-
processing: -> (blob) { "/placeholders/processing.svg" }
|
|
247
|
-
|
|
248
|
-
# Distinct placeholder when permanently failed
|
|
249
|
-
attachable.variant :web,
|
|
250
|
-
processing: :original,
|
|
251
|
-
failed: "/icons/broken.svg"
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
Both options accept the same set of values: `:original`, `:blank`, a String URL, or a Proc that receives the blob.
|
|
232
|
+
There is nothing to configure. A variant's `.url` serves the original while it is pending, processing, or failed, and the processed variant once ready. With `async: true` on `image_tag`/`video_tag`, the gem instead shows a circular progress bar while processing, and a red error glyph -- the same progress bar in its error state -- once the variant has permanently failed.
|
|
255
233
|
|
|
256
234
|
## Failure Handling
|
|
257
235
|
|
|
@@ -276,7 +254,7 @@ variant.error # => "ffmpeg exited with status 1: ..."
|
|
|
276
254
|
5. The external service processes the file, uploads the result to the destination URL
|
|
277
255
|
6. The external service POSTs to the callback URL with success/failure status
|
|
278
256
|
7. The gem's callback endpoint transitions the `VariantRecord` to `processed` or `failed`
|
|
279
|
-
8. When a view requests the variant URL, the gem checks state and serves the variant or
|
|
257
|
+
8. When a view requests the variant URL, the gem checks state and serves the processed variant or, while pending/processing/failed, the original
|
|
280
258
|
|
|
281
259
|
### Inline transformer flow
|
|
282
260
|
|
|
@@ -284,7 +262,7 @@ variant.error # => "ffmpeg exited with status 1: ..."
|
|
|
284
262
|
4. The job calls the transformer's `process` method, blocking until complete
|
|
285
263
|
5. On success, the output is uploaded, the `VariantRecord` transitions to `processed`
|
|
286
264
|
6. On failure, the error is recorded and the job is re-enqueued (up to 3 attempts)
|
|
287
|
-
7. When a view requests the variant URL, the gem checks state and serves the variant or
|
|
265
|
+
7. When a view requests the variant URL, the gem checks state and serves the processed variant or, while pending/processing/failed, the original
|
|
288
266
|
|
|
289
267
|
## License
|
|
290
268
|
|
|
@@ -98,8 +98,13 @@ const STYLES = `
|
|
|
98
98
|
stroke: var(--indeterminate-color);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
/* The X of the error glyph; revealed by [error]. */
|
|
102
|
+
.error-mark {
|
|
103
|
+
display: none;
|
|
104
|
+
fill: none;
|
|
102
105
|
stroke: var(--error-color);
|
|
106
|
+
stroke-width: var(--circular-thickness);
|
|
107
|
+
stroke-linecap: round;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
.label {
|
|
@@ -132,6 +137,12 @@ const STYLES = `
|
|
|
132
137
|
0% { transform: rotate(-90deg); }
|
|
133
138
|
100% { transform: rotate(270deg); }
|
|
134
139
|
}
|
|
140
|
+
|
|
141
|
+
/* Error (circular): static full ring + X. Last so it beats the indeterminate rules. */
|
|
142
|
+
:host([error]) .track { stroke: var(--error-color); }
|
|
143
|
+
:host([error]) .progress-ring { display: none; }
|
|
144
|
+
:host([error]) .ring { animation: none; }
|
|
145
|
+
:host([error]) .error-mark { display: block; }
|
|
135
146
|
`;
|
|
136
147
|
|
|
137
148
|
const LINEAR_HTML = `
|
|
@@ -144,6 +155,7 @@ const CIRCULAR_HTML = `
|
|
|
144
155
|
<svg class="ring" viewBox="0 0 100 100">
|
|
145
156
|
<circle class="track" cx="50" cy="50" r="${CIRCLE_RADIUS}"></circle>
|
|
146
157
|
<circle class="progress-ring" cx="50" cy="50" r="${CIRCLE_RADIUS}" pathLength="${PATH_LENGTH}"></circle>
|
|
158
|
+
<path class="error-mark" d="M37 37 L63 63 M63 37 L37 63"></path>
|
|
147
159
|
</svg>
|
|
148
160
|
<span class="label"><slot></slot></span>
|
|
149
161
|
</div>
|
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
media box (and the progress bar centered on it) coincides with the parent
|
|
3
3
|
figure box, instead of sitting in a taller line box from the baseline gap --
|
|
4
4
|
otherwise an overlay centered on the figure lands off-center from the ring. */
|
|
5
|
-
turbo-frame:has(> .async-variant-processing)
|
|
5
|
+
turbo-frame:has(> .async-variant-processing),
|
|
6
|
+
turbo-frame:has(> .async-variant-failed) {
|
|
6
7
|
display: block;
|
|
7
8
|
}
|
|
8
|
-
.async-variant-processing
|
|
9
|
+
.async-variant-processing,
|
|
10
|
+
.async-variant-failed {
|
|
9
11
|
position: relative;
|
|
10
12
|
display: block;
|
|
11
13
|
}
|
|
12
|
-
/* Present to reserve the
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
descender so the ring centers on the
|
|
14
|
+
/* Present to reserve the variant's box, but rendered invisible via opacity
|
|
15
|
+
rather than visibility/display -- so it still has layout and stays queryable
|
|
16
|
+
by Capybara. The progress bar floats over it. block drops the baseline
|
|
17
|
+
descender so the ring centers on the box, not a few px below it. */
|
|
16
18
|
.async-variant-processing img,
|
|
17
|
-
.async-variant-processing video
|
|
19
|
+
.async-variant-processing video,
|
|
20
|
+
.async-variant-failed img,
|
|
21
|
+
.async-variant-failed video {
|
|
18
22
|
opacity: 0.001;
|
|
19
23
|
display: block;
|
|
20
24
|
}
|
|
@@ -27,4 +31,5 @@ turbo-frame:has(> .async-variant-processing) {
|
|
|
27
31
|
--progress-color: #2E7D32;
|
|
28
32
|
--indeterminate-color: #2E7D32;
|
|
29
33
|
--track-color: rgba(0, 0, 0, 0.25);
|
|
34
|
+
--error-color: #ff7c81;
|
|
30
35
|
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
<div class="async-variant-state async-variant-failed">
|
|
2
|
-
<%=
|
|
2
|
+
<%= async_variant_placeholder_tag(variant, html_options, kind: kind) %>
|
|
3
|
+
|
|
4
|
+
<%= content_tag "progress-bar", "", mode: "circular", error: "", class: "async-variant-progress" %>
|
|
5
|
+
|
|
6
|
+
<%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
|
|
7
|
+
<%= ActiveStorage::AsyncVariants.javascript_include_tag "progress-bar", type: "module" %>
|
|
3
8
|
|
|
4
9
|
<% if ActiveStorage::AsyncVariants.retry_visible?(self) %>
|
|
5
10
|
<% dialog_id = "#{async_variant_frame_id(variant)}-error" %>
|
|
6
11
|
<% form_id = "#{dialog_id}-form" %>
|
|
7
12
|
<%# Light DOM, so the custom property inherits into the shadow root. %>
|
|
8
13
|
<style>
|
|
9
|
-
.async-variant-failed {
|
|
10
|
-
position: relative;
|
|
11
|
-
display: inline-block;
|
|
12
|
-
--retry-opener-display: none;
|
|
13
|
-
}
|
|
14
|
+
.async-variant-failed { --retry-opener-display: none; }
|
|
14
15
|
.async-variant-failed:hover { --retry-opener-display: inline-flex; }
|
|
15
16
|
</style>
|
|
16
17
|
<%# In the light DOM so its submit reaches Turbo, and so preventDefault on the
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<div class="async-variant-state async-variant-processing">
|
|
2
|
-
|
|
3
|
-
<%= send method, async_variant_representation_path(variant), **html_options.symbolize_keys %>
|
|
2
|
+
<%= async_variant_placeholder_tag(variant, html_options, kind: kind) %>
|
|
4
3
|
|
|
5
4
|
<%= content_tag "progress-bar", "", mode: "circular", class: "async-variant-progress", percent: variant.progress %>
|
|
6
5
|
</div>
|
|
@@ -37,3 +37,4 @@ Feature: image_tag with async: true emits the right markup per variant state
|
|
|
37
37
|
When I visit the avatar page for the :thumb_proc variant
|
|
38
38
|
Then the page should contain a turbo-frame
|
|
39
39
|
And the frame should render the failed state
|
|
40
|
+
And the frame should render an error progress bar
|
|
@@ -24,6 +24,10 @@ Then /^the frame should render a progress bar$/ do
|
|
|
24
24
|
expect(page).to have_css("turbo-frame progress-bar", visible: :all)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
Then /^the frame should render an error progress bar$/ do
|
|
28
|
+
expect(page).to have_css("turbo-frame progress-bar[error]", visible: :all)
|
|
29
|
+
end
|
|
30
|
+
|
|
27
31
|
Then /^the progress bar should be indeterminate$/ do
|
|
28
32
|
expect(page).to have_css("turbo-frame progress-bar:not([percent])", visible: :all)
|
|
29
33
|
end
|
|
@@ -14,7 +14,7 @@ module ActiveStorage
|
|
|
14
14
|
return unless blob.bucket_backed?
|
|
15
15
|
|
|
16
16
|
named_variants.each do |name, named_variant|
|
|
17
|
-
next unless named_variant.transformations.key?(:
|
|
17
|
+
next unless named_variant.transformations.key?(:async)
|
|
18
18
|
|
|
19
19
|
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
20
20
|
record, self.name, name.to_s,
|
|
@@ -7,6 +7,25 @@ module ActiveStorage
|
|
|
7
7
|
# `helper ActiveStorage::AsyncVariants::Helper` (so the state partials can
|
|
8
8
|
# use them).
|
|
9
9
|
module Helper
|
|
10
|
+
# 1x1 transparent GIF: reserves the variant's box without fetching the
|
|
11
|
+
# original through the representations path on every poll.
|
|
12
|
+
PLACEHOLDER_SRC = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
|
13
|
+
|
|
14
|
+
def async_variant_placeholder_tag(variant, html_options = {}, kind: :image)
|
|
15
|
+
opts = async_variant_box_dimensions(variant)
|
|
16
|
+
.merge(html_options.symbolize_keys.except(:src, :controls, :autoplay, :preload, :poster))
|
|
17
|
+
# A video placeholder carries no <source>, so it reserves the box
|
|
18
|
+
# without loading anything -- same zero-network intent as the GIF.
|
|
19
|
+
kind == :video ? content_tag(:video, "", opts) : image_tag(PLACEHOLDER_SRC, opts)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def async_variant_box_dimensions(variant)
|
|
23
|
+
resize = variant.variation.transformations
|
|
24
|
+
.values_at(:resize_to_limit, :resize_to_fit, :resize_to_fill)
|
|
25
|
+
.compact.first
|
|
26
|
+
resize.is_a?(Array) ? { width: resize[0], height: resize[1] } : {}
|
|
27
|
+
end
|
|
28
|
+
|
|
10
29
|
# In test with non-bucket-backed services, the gem defers to vanilla
|
|
11
30
|
# ActiveStorage (synchronous vips transform) -- inline rendering keeps
|
|
12
31
|
# those environments simple. Otherwise, only inline a normal <img> when
|
|
@@ -67,15 +67,15 @@ module ActiveStorage
|
|
|
67
67
|
private
|
|
68
68
|
|
|
69
69
|
def async_preview?
|
|
70
|
-
resolved_async_options[:
|
|
70
|
+
resolved_async_options[:async].present?
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
# Variations rebuilt from the redirect URL only carry transformations --
|
|
74
|
-
# :
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
74
|
+
# :async / :transformer are stripped at Variation#initialize and not
|
|
75
|
+
# embedded in the URL key. Recover them via the digest-keyed registry that
|
|
76
|
+
# VariationExtension warms on every view-side variant call, or fall back to
|
|
77
|
+
# scanning attached named variants when the registry is cold (autoloader
|
|
78
|
+
# hasn't touched the consumer model yet).
|
|
79
79
|
def resolved_async_options
|
|
80
80
|
@resolved_async_options ||=
|
|
81
81
|
variation.async_options.presence ||
|
|
@@ -90,7 +90,7 @@ module ActiveStorage
|
|
|
90
90
|
attachment.send(:named_variants).each do |name, _|
|
|
91
91
|
candidate = attachment.variant(name.to_sym)
|
|
92
92
|
next unless candidate.variation.transformations.to_json == target
|
|
93
|
-
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:
|
|
93
|
+
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
nil
|
|
@@ -107,25 +107,9 @@ module ActiveStorage
|
|
|
107
107
|
blob.variant_records.find_by(variation_digest: variation.digest)
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# Serve the original until the preview variant is processed.
|
|
110
111
|
def fallback_preview_url(...)
|
|
111
|
-
|
|
112
|
-
when :original then blob.url(...)
|
|
113
|
-
when :blank then nil
|
|
114
|
-
when Proc then active_fallback.call(blob)
|
|
115
|
-
when String then active_fallback
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def active_fallback
|
|
120
|
-
if failed?
|
|
121
|
-
resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
|
|
122
|
-
else
|
|
123
|
-
resolved_async_options[:processing]
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def failed?
|
|
128
|
-
find_preview_variant_record&.state == "failed"
|
|
112
|
+
blob.url(...)
|
|
129
113
|
end
|
|
130
114
|
end
|
|
131
115
|
end
|
|
@@ -7,7 +7,7 @@ module ActiveStorage
|
|
|
7
7
|
#
|
|
8
8
|
# has_one_attached :avatar do |attachable|
|
|
9
9
|
# attachable.variant :thumb, resize_to_limit: [150, 150], format: "webp",
|
|
10
|
-
# transformer: Crucible,
|
|
10
|
+
# transformer: Crucible, async: true
|
|
11
11
|
# end
|
|
12
12
|
#
|
|
13
13
|
# Without this, the Registry only warms when view-side code calls
|
|
@@ -7,7 +7,7 @@ module ActiveStorage
|
|
|
7
7
|
# Process-wide lookup of async_options keyed by Variation#digest.
|
|
8
8
|
#
|
|
9
9
|
# Populated lazily by VariationExtension#initialize whenever a Variation is
|
|
10
|
-
# constructed with :
|
|
10
|
+
# constructed with :async in its async_options -- which happens on
|
|
11
11
|
# every view-side `attachment.variant(:name)` call. The redirect controller
|
|
12
12
|
# then resolves async_options by digest without scanning blob.attachments
|
|
13
13
|
# for a transformations-match.
|
|
@@ -13,8 +13,8 @@ module ActiveStorage
|
|
|
13
13
|
|
|
14
14
|
# processed and failed are terminal states: cache fragments built when
|
|
15
15
|
# state was pending/processing need to be invalidated so the next
|
|
16
|
-
# render sees the new state (
|
|
17
|
-
# swaps src to the direct CDN URL, etc.).
|
|
16
|
+
# render sees the new state (swaps the processing box for the failed
|
|
17
|
+
# error state, swaps src to the direct CDN URL, etc.).
|
|
18
18
|
def reached_terminal_state?
|
|
19
19
|
saved_change_to_state? && %w[processed failed].include?(state)
|
|
20
20
|
end
|
|
@@ -27,15 +27,8 @@ module ActiveStorage
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def url(...)
|
|
30
|
-
if blob.bucket_backed? && !processed?
|
|
31
|
-
|
|
32
|
-
case fallback
|
|
33
|
-
when :original then blob.url(...)
|
|
34
|
-
when :blank then nil
|
|
35
|
-
when Proc then fallback.call(blob)
|
|
36
|
-
when String then fallback
|
|
37
|
-
else super
|
|
38
|
-
end
|
|
30
|
+
if blob.bucket_backed? && async_variant? && !processed?
|
|
31
|
+
blob.url(...)
|
|
39
32
|
else
|
|
40
33
|
super
|
|
41
34
|
end
|
|
@@ -84,6 +77,10 @@ module ActiveStorage
|
|
|
84
77
|
|
|
85
78
|
private
|
|
86
79
|
|
|
80
|
+
def async_variant?
|
|
81
|
+
resolved_async_options[:async].present?
|
|
82
|
+
end
|
|
83
|
+
|
|
87
84
|
def resolved_async_options
|
|
88
85
|
@resolved_async_options ||=
|
|
89
86
|
variation.async_options.presence ||
|
|
@@ -92,14 +89,6 @@ module ActiveStorage
|
|
|
92
89
|
{}
|
|
93
90
|
end
|
|
94
91
|
|
|
95
|
-
def active_fallback
|
|
96
|
-
if failed?
|
|
97
|
-
resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
|
|
98
|
-
else
|
|
99
|
-
resolved_async_options[:processing]
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
92
|
# Cold-path scan: used by enqueue! (which needs the attachment +
|
|
104
93
|
# variant_name to dispatch ProcessJob) and by resolved_async_options as
|
|
105
94
|
# a fallback when the Registry is cold (e.g. in dev, when a
|
|
@@ -126,7 +115,7 @@ module ActiveStorage
|
|
|
126
115
|
attachment.send(:named_variants).each do |name, _|
|
|
127
116
|
candidate = attachment.variant(name.to_sym)
|
|
128
117
|
if candidate.variation.transformations.to_json == target
|
|
129
|
-
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:
|
|
118
|
+
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
|
|
130
119
|
end
|
|
131
120
|
end
|
|
132
121
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
5
|
module VariationExtension
|
|
6
|
-
ASYNC_KEYS = %i[
|
|
6
|
+
ASYNC_KEYS = %i[async transformer].freeze
|
|
7
7
|
|
|
8
8
|
def initialize(transformations)
|
|
9
9
|
if transformations.is_a?(Hash)
|
|
@@ -13,7 +13,7 @@ module ActiveStorage
|
|
|
13
13
|
@async_options = {}
|
|
14
14
|
super
|
|
15
15
|
end
|
|
16
|
-
ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:
|
|
16
|
+
ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:async]
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def async_options
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_storage-async_variants
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Micah Geisel
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-06-
|
|
10
|
+
date: 2026-06-08 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activestorage
|