active_storage-async_variants 0.6.0 → 0.8.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 +12 -0
- data/CLAUDE.md +4 -4
- data/README.md +14 -36
- data/app/assets/javascripts/progress-bar.js +80 -2
- data/app/assets/stylesheets/progress.css +12 -7
- data/app/controllers/active_storage/async_variants/states_controller.rb +10 -1
- data/app/views/active_storage/async_variants/states/_failed.html.erb +7 -6
- data/app/views/active_storage/async_variants/states/_processing.html.erb +2 -3
- data/config/routes.rb +8 -0
- data/db/migrate/20260608000001_add_created_at_to_variant_records.rb +7 -0
- 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 +21 -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 +16 -18
- data/lib/active_storage/async_variants/variation_extension.rb +2 -2
- data/lib/active_storage/async_variants/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34c672d7d6d989bb7c633aa1e57e081d6879a8ea9695ffd65997b9df9cf8f8ab
|
|
4
|
+
data.tar.gz: aca54bb9b2ae6af16016f1e96e6e3f221bc7029b8eca9c145535eadefeb57d66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e86b3777c71d70d2014fe439fb91f395f1711cce40efefa3c5e5291a22bd2fc6d1933aad3a1c095557726e45547d7112f9e2f2bbd24fece75726adebddd6f686
|
|
7
|
+
data.tar.gz: 591deb4316c685316084ebb26904e5fe5b2ecbea0328cc832469ae4185388239511e35507b53264e137cf85153bfe1a846e7bd0491ba023bb991333653cfb532
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.8.0]
|
|
2
|
+
|
|
3
|
+
- The processing progress bar now advances smoothly between polls: variants expose a `rate` (percent-per-second, derived from the record's `created_at`, last heartbeat, and reported progress) that the `@botandrose/progress-bar` element uses to optimistically creep forward, instead of only stepping on each refresh. Includes a migration adding a `created_at` column to `active_storage_variant_records`; run it before deploying.
|
|
4
|
+
- Fix: the processing/failed placeholder image now points at a tiny gem-served 1x1 GIF whose URL ends in the media's filename, instead of a `data:` URI. Restores filename-based identification of the placeholder (e.g. for media-table diff assertions) without fetching the original through a representation. Video placeholders remain a source-less `<video>`.
|
|
5
|
+
|
|
6
|
+
## [0.7.0]
|
|
7
|
+
|
|
8
|
+
- **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.
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
1
13
|
## [0.6.0]
|
|
2
14
|
|
|
3
15
|
- 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
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const CIRCLE_RADIUS = 40; // fallback r attribute; CSS shrinks it to fit the stroke
|
|
2
2
|
const PATH_LENGTH = 100; // normalize the arc so dash math is independent of the radius
|
|
3
|
+
const RATE_HZ = 30; // optimistic auto-advance tick frequency
|
|
3
4
|
|
|
4
5
|
const STYLES = `
|
|
5
6
|
:host {
|
|
@@ -98,8 +99,13 @@ const STYLES = `
|
|
|
98
99
|
stroke: var(--indeterminate-color);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
/* The X of the error glyph; revealed by [error]. */
|
|
103
|
+
.error-mark {
|
|
104
|
+
display: none;
|
|
105
|
+
fill: none;
|
|
102
106
|
stroke: var(--error-color);
|
|
107
|
+
stroke-width: var(--circular-thickness);
|
|
108
|
+
stroke-linecap: round;
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
.label {
|
|
@@ -132,6 +138,12 @@ const STYLES = `
|
|
|
132
138
|
0% { transform: rotate(-90deg); }
|
|
133
139
|
100% { transform: rotate(270deg); }
|
|
134
140
|
}
|
|
141
|
+
|
|
142
|
+
/* Error (circular): static full ring + X. Last so it beats the indeterminate rules. */
|
|
143
|
+
:host([error]) .track { stroke: var(--error-color); }
|
|
144
|
+
:host([error]) .progress-ring { display: none; }
|
|
145
|
+
:host([error]) .ring { animation: none; }
|
|
146
|
+
:host([error]) .error-mark { display: block; }
|
|
135
147
|
`;
|
|
136
148
|
|
|
137
149
|
const LINEAR_HTML = `
|
|
@@ -144,6 +156,7 @@ const CIRCULAR_HTML = `
|
|
|
144
156
|
<svg class="ring" viewBox="0 0 100 100">
|
|
145
157
|
<circle class="track" cx="50" cy="50" r="${CIRCLE_RADIUS}"></circle>
|
|
146
158
|
<circle class="progress-ring" cx="50" cy="50" r="${CIRCLE_RADIUS}" pathLength="${PATH_LENGTH}"></circle>
|
|
159
|
+
<path class="error-mark" d="M37 37 L63 63 M63 37 L37 63"></path>
|
|
147
160
|
</svg>
|
|
148
161
|
<span class="label"><slot></slot></span>
|
|
149
162
|
</div>
|
|
@@ -166,12 +179,21 @@ function clampPercent(value) {
|
|
|
166
179
|
return Math.min(100, Math.max(0, number));
|
|
167
180
|
}
|
|
168
181
|
|
|
182
|
+
function parseRate(value) {
|
|
183
|
+
const number = Number(value);
|
|
184
|
+
if (Number.isNaN(number)) {
|
|
185
|
+
throw new TypeError(`progress-bar: rate must be numeric, got ${JSON.stringify(value)}`);
|
|
186
|
+
}
|
|
187
|
+
return number;
|
|
188
|
+
}
|
|
189
|
+
|
|
169
190
|
class ProgressBar extends HTMLElement {
|
|
170
191
|
constructor() {
|
|
171
192
|
super();
|
|
172
193
|
this.attachShadow({ mode: 'open' });
|
|
173
194
|
this._percent = null;
|
|
174
195
|
this._renderedMode = null;
|
|
196
|
+
this._rateTimer = null;
|
|
175
197
|
}
|
|
176
198
|
|
|
177
199
|
connectedCallback() {
|
|
@@ -180,6 +202,11 @@ class ProgressBar extends HTMLElement {
|
|
|
180
202
|
this.setAttribute('aria-valuemin', '0');
|
|
181
203
|
this.setAttribute('aria-valuemax', '100');
|
|
182
204
|
this.updateBar();
|
|
205
|
+
this._syncTicker();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
disconnectedCallback() {
|
|
209
|
+
this._stopTicker();
|
|
183
210
|
}
|
|
184
211
|
|
|
185
212
|
get percent() {
|
|
@@ -202,6 +229,19 @@ class ProgressBar extends HTMLElement {
|
|
|
202
229
|
this.toggleAttribute('error', Boolean(value));
|
|
203
230
|
}
|
|
204
231
|
|
|
232
|
+
get rate() {
|
|
233
|
+
const value = this.getAttribute('rate');
|
|
234
|
+
return value === null ? null : Number(value);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
set rate(value) {
|
|
238
|
+
if (value === null || value === undefined) {
|
|
239
|
+
this.removeAttribute('rate');
|
|
240
|
+
} else {
|
|
241
|
+
this.setAttribute('rate', parseRate(value));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
get indeterminate() {
|
|
206
246
|
return !this.hasAttribute('percent');
|
|
207
247
|
}
|
|
@@ -215,16 +255,22 @@ class ProgressBar extends HTMLElement {
|
|
|
215
255
|
}
|
|
216
256
|
|
|
217
257
|
static get observedAttributes() {
|
|
218
|
-
return ['percent', 'mode'];
|
|
258
|
+
return ['percent', 'mode', 'rate'];
|
|
219
259
|
}
|
|
220
260
|
|
|
221
261
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
222
262
|
if (name === 'percent') {
|
|
223
263
|
this._percent = newValue === null ? null : clampPercent(newValue);
|
|
224
264
|
}
|
|
265
|
+
if (name === 'rate' && newValue !== null) {
|
|
266
|
+
parseRate(newValue);
|
|
267
|
+
}
|
|
225
268
|
if (name === 'mode') {
|
|
226
269
|
this.render();
|
|
227
270
|
}
|
|
271
|
+
if (name === 'percent' || name === 'rate') {
|
|
272
|
+
this._syncTicker();
|
|
273
|
+
}
|
|
228
274
|
this.updateBar();
|
|
229
275
|
}
|
|
230
276
|
|
|
@@ -251,6 +297,38 @@ class ProgressBar extends HTMLElement {
|
|
|
251
297
|
}
|
|
252
298
|
}
|
|
253
299
|
|
|
300
|
+
// Optimistic auto-advance: while a nonzero rate is set on a determinate bar,
|
|
301
|
+
// creep percent forward on its own. Indeterminate bars ignore rate entirely.
|
|
302
|
+
_syncTicker() {
|
|
303
|
+
const rate = Number(this.getAttribute('rate'));
|
|
304
|
+
const shouldRun = this.isConnected && !this.indeterminate && Number.isFinite(rate) && rate !== 0;
|
|
305
|
+
if (shouldRun && this._rateTimer === null) {
|
|
306
|
+
this._rateTimer = setInterval(() => this._tick(), 1000 / RATE_HZ);
|
|
307
|
+
} else if (!shouldRun) {
|
|
308
|
+
this._stopTicker();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_stopTicker() {
|
|
313
|
+
if (this._rateTimer !== null) {
|
|
314
|
+
clearInterval(this._rateTimer);
|
|
315
|
+
this._rateTimer = null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
_tick() {
|
|
320
|
+
const rate = Number(this.getAttribute('rate'));
|
|
321
|
+
const next = clampPercent(this._percent + rate / RATE_HZ);
|
|
322
|
+
if (next !== this._percent) {
|
|
323
|
+
this.percent = next;
|
|
324
|
+
}
|
|
325
|
+
// Hitting the bound it's heading toward retires the rate; a later percent
|
|
326
|
+
// change can re-arm it.
|
|
327
|
+
if (next === 0 || next === 100) {
|
|
328
|
+
this.removeAttribute('rate');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
254
332
|
render() {
|
|
255
333
|
const mode = this.mode;
|
|
256
334
|
if (this._renderedMode === mode) {
|
|
@@ -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
|
}
|
|
@@ -11,11 +11,20 @@ module ActiveStorage
|
|
|
11
11
|
|
|
12
12
|
layout false
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# 1x1 transparent GIF served at a filename-bearing URL, so the
|
|
15
|
+
# processing/failed <img> identifies its media without fetching anything.
|
|
16
|
+
TRANSPARENT_GIF = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7".unpack1("m").freeze
|
|
17
|
+
|
|
18
|
+
before_action :set_variant, only: %i[show retry]
|
|
15
19
|
|
|
16
20
|
def show
|
|
17
21
|
end
|
|
18
22
|
|
|
23
|
+
def placeholder
|
|
24
|
+
expires_in 1.year, public: true
|
|
25
|
+
send_data TRANSPARENT_GIF, type: "image/gif", disposition: "inline"
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
def retry
|
|
20
29
|
@variant.blob.variant_records.where(
|
|
21
30
|
variation_digest: @variant.variation.digest,
|
|
@@ -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,8 +1,7 @@
|
|
|
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
|
-
<%= content_tag "progress-bar", "", mode: "circular", class: "async-variant-progress", percent: variant.progress %>
|
|
4
|
+
<%= content_tag "progress-bar", "", mode: "circular", class: "async-variant-progress", percent: variant.progress, rate: variant.progress_rate %>
|
|
6
5
|
</div>
|
|
7
6
|
|
|
8
7
|
<%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
|
data/config/routes.rb
CHANGED
|
@@ -12,5 +12,13 @@ Rails.application.routes.draw do
|
|
|
12
12
|
to: "active_storage/async_variants/states#retry",
|
|
13
13
|
as: :async_variant_state_retry
|
|
14
14
|
|
|
15
|
+
# Tiny 1x1 GIF whose URL carries the media's filename, so the processing/failed
|
|
16
|
+
# box reserves layout without fetching the original through a representation.
|
|
17
|
+
get "/active_storage/async_variants/placeholder/:filename",
|
|
18
|
+
to: "active_storage/async_variants/states#placeholder",
|
|
19
|
+
as: :async_variant_placeholder,
|
|
20
|
+
constraints: { filename: %r{[^/]+} },
|
|
21
|
+
format: false
|
|
22
|
+
|
|
15
23
|
ActiveStorage::AsyncVariants::Assets.draw(self, "/active_storage/async_variants/assets")
|
|
16
24
|
end
|
|
@@ -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,27 @@ module ActiveStorage
|
|
|
7
7
|
# `helper ActiveStorage::AsyncVariants::Helper` (so the state partials can
|
|
8
8
|
# use them).
|
|
9
9
|
module Helper
|
|
10
|
+
# Invisible box reserving layout for the progress bar. The image src is a
|
|
11
|
+
# tiny gem GIF whose URL ends in the media filename (no original fetched);
|
|
12
|
+
# the video placeholder carries no <source>.
|
|
13
|
+
def async_variant_placeholder_tag(variant, html_options = {}, kind: :image)
|
|
14
|
+
opts = async_variant_box_dimensions(variant)
|
|
15
|
+
.merge(html_options.symbolize_keys.except(:src, :controls, :autoplay, :preload, :poster))
|
|
16
|
+
if kind == :video
|
|
17
|
+
content_tag(:video, "", opts)
|
|
18
|
+
else
|
|
19
|
+
src = Rails.application.routes.url_helpers.async_variant_placeholder_path(variant.blob.filename.to_s)
|
|
20
|
+
image_tag(src, opts)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def async_variant_box_dimensions(variant)
|
|
25
|
+
resize = variant.variation.transformations
|
|
26
|
+
.values_at(:resize_to_limit, :resize_to_fit, :resize_to_fill)
|
|
27
|
+
.compact.first
|
|
28
|
+
resize.is_a?(Array) ? { width: resize[0], height: resize[1] } : {}
|
|
29
|
+
end
|
|
30
|
+
|
|
10
31
|
# In test with non-bucket-backed services, the gem defers to vanilla
|
|
11
32
|
# ActiveStorage (synchronous vips transform) -- inline rendering keeps
|
|
12
33
|
# 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
|
|
@@ -82,8 +75,21 @@ module ActiveStorage
|
|
|
82
75
|
async_record&.last_heartbeat_at
|
|
83
76
|
end
|
|
84
77
|
|
|
78
|
+
# Percent-per-second observed so far (progress over elapsed processing
|
|
79
|
+
# time); drives the progress-bar's optimistic creep between reloads.
|
|
80
|
+
def progress_rate
|
|
81
|
+
record = async_record
|
|
82
|
+
return nil unless record&.created_at && record.last_heartbeat_at && record.progress&.positive?
|
|
83
|
+
elapsed = record.last_heartbeat_at - record.created_at
|
|
84
|
+
elapsed.positive? ? (record.progress / elapsed).round(4) : nil
|
|
85
|
+
end
|
|
86
|
+
|
|
85
87
|
private
|
|
86
88
|
|
|
89
|
+
def async_variant?
|
|
90
|
+
resolved_async_options[:async].present?
|
|
91
|
+
end
|
|
92
|
+
|
|
87
93
|
def resolved_async_options
|
|
88
94
|
@resolved_async_options ||=
|
|
89
95
|
variation.async_options.presence ||
|
|
@@ -92,14 +98,6 @@ module ActiveStorage
|
|
|
92
98
|
{}
|
|
93
99
|
end
|
|
94
100
|
|
|
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
101
|
# Cold-path scan: used by enqueue! (which needs the attachment +
|
|
104
102
|
# variant_name to dispatch ProcessJob) and by resolved_async_options as
|
|
105
103
|
# a fallback when the Registry is cold (e.g. in dev, when a
|
|
@@ -126,7 +124,7 @@ module ActiveStorage
|
|
|
126
124
|
attachment.send(:named_variants).each do |name, _|
|
|
127
125
|
candidate = attachment.variant(name.to_sym)
|
|
128
126
|
if candidate.variation.transformations.to_json == target
|
|
129
|
-
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:
|
|
127
|
+
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
|
|
130
128
|
end
|
|
131
129
|
end
|
|
132
130
|
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.8.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
|
|
@@ -79,6 +79,7 @@ files:
|
|
|
79
79
|
- app/views/active_storage/async_variants/states/show.html.erb
|
|
80
80
|
- config/routes.rb
|
|
81
81
|
- db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb
|
|
82
|
+
- db/migrate/20260608000001_add_created_at_to_variant_records.rb
|
|
82
83
|
- features/retry_flow.feature
|
|
83
84
|
- features/retry_visibility.feature
|
|
84
85
|
- features/state_rendering.feature
|