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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3d828692f86ff7b409b93f3198daae20c5c6821a0cce10da31b625abe7c04be
4
- data.tar.gz: '0319ea7873525ea5d76a930e195687b3ba28bebd58922e3fc4aa821238241f28'
3
+ metadata.gz: 34c672d7d6d989bb7c633aa1e57e081d6879a8ea9695ffd65997b9df9cf8f8ab
4
+ data.tar.gz: aca54bb9b2ae6af16016f1e96e6e3f221bc7029b8eca9c145535eadefeb57d66
5
5
  SHA512:
6
- metadata.gz: 9e80ebc6059bf84853cf7bf05d824ed5d25d5b195a4702d86495cdb29366eb02ee390558768fa66a1f5ea85e8e86987a46f9c7a2492de59e1a7287b25ef7074a
7
- data.tar.gz: 23dc024c9a1ceaa334580d32af52f20e4e327f3d40d22e438616bdc0077f5b62f95deb50670ee5a30b3a1e72716e2ef9604bd6eabe2fc3a699aaa21142518ad8
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 `processing:` option on a variant definition opts it into async processing.
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 (`processing:`, `failed:`, `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 `processing:`
24
- - **`VariantWithRecordExtension`** → `ActiveStorage::VariantWithRecord` — overrides URL generation to serve the `processing:` (or `failed:`) placeholder while not ready; adds state query methods (`processed?`, `processing?`, `pending?`, `failed?`)
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 `processing:` to any named variant to opt into the async pipeline. The value is what to serve while the variant is being processed:
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
- processing: :original
31
+ async: true
32
32
  end
33
33
  end
34
34
  ```
35
35
 
36
- The presence of `processing:` 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:
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
- processing: :original
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
- processing: :original
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
- If the variant is still processing, this serves the original video. Once processing completes, it serves the transcoded variant.
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 swaps in placeholder content 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`:
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: spinner while pending,
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 placeholder). %>
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, placeholder while pending,
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
- ## Placeholder Options
230
+ ## Placeholders
231
231
 
232
- The `processing:` option controls what gets served while a variant is being processed. The `failed:` option (optional) controls what gets served once the variant has permanently failed; if omitted, it defaults to the `processing:` value.
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 the `processing:`/`failed:` placeholder
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 the `processing:`/`failed:` placeholder
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
- :host([error]) .progress-ring {
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 processed media's box, but rendered invisible via
13
- opacity rather than visibility/display -- so it still has layout and stays
14
- queryable by Capybara. The spinner floats over it. block drops the baseline
15
- descender so the ring centers on the media, not a few px below it. */
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
- before_action :set_variant
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
- <%= image_tag async_variant_representation_path(variant), **html_options.symbolize_keys.except(:controls, :autoplay, :preload) %>
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
- <% method = kind == :video ? :video_tag : :image_tag %>
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCreatedAtToVariantRecords < ActiveRecord::Migration[7.2]
4
+ def change
5
+ add_column :active_storage_variant_records, :created_at, :datetime, if_not_exists: true
6
+ end
7
+ 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?(:processing)
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[:transformer].present?
70
+ resolved_async_options[:async].present?
71
71
  end
72
72
 
73
73
  # Variations rebuilt from the redirect URL only carry transformations --
74
- # :transformer / :processing / :failed are stripped at Variation#initialize
75
- # and not embedded in the URL key. Recover them via the digest-keyed
76
- # registry that VariationExtension warms on every view-side variant call,
77
- # or fall back to scanning attached named variants when the registry is
78
- # cold (autoloader hasn't touched the consumer model yet).
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[:transformer].present?
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
- case active_fallback
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, processing: "/spinner.svg"
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 :transformer in its async_options -- which happens on
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 (and serves the failed: fallback URL,
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
- fallback = active_fallback
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[:processing].present?
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[processing failed transformer].freeze
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[:processing].present?
16
+ ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:async]
17
17
  end
18
18
 
19
19
  def async_options
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
- VERSION = "0.6.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
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.6.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-07 00:00:00.000000000 Z
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