active_storage-async_variants 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc926d64601c238db11edd005f220c0842cbf4316f405d43c23d08a99ff874de
4
- data.tar.gz: 177a3d4a3647e41ac5e23db1908265c556a8c4c006b5497cbb802b2cf9ebb02e
3
+ metadata.gz: 259c11526314988d60e78e9e176dc16105c6aeb578b1774693a666fc1e277a21
4
+ data.tar.gz: 92fc3546ed708ed709ad4fedc2d7e6258d765719fad75571844671a902f1b485
5
5
  SHA512:
6
- metadata.gz: 25f4d2430ce2547c9a4109bd648f338914e92cacdb38bf2e651cdfff21a9a045e90480c10e6170f0723dbcb5e675a35370cda7c91ced43ec41c5bfa3f112f2a1
7
- data.tar.gz: c02fac50058fd3465435bf194e09ffb0732deda2c61316b8e960b1179e4ee8e9eb417e1dd192fdf564c0e059d87a674fe76aa852f99a385592ddfcd71fb1a453
6
+ metadata.gz: ebfe0839de2603c91a5705f3942b30b3db7685d20ce5d3d2425f98472a191d559ac9103bab5a59972e09f18385b8829283fe0323f2de4810e8619cb57e028ab2
7
+ data.tar.gz: 165f5796f098a3f40194a9d893ba07b98bd7b3a7ac8601fda744f5dcb1e4a98c0d69e1514e788f612ea8fa1dbf4bca6c44cbb7ee5ef072fcf5fa13fe03ce5b56
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
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
+
8
+ ## [0.6.0]
9
+
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.
11
+ - Processing variants render a progress bar — indeterminate until progress is reported, then a determinate percentage — served as a cached asset. The turbo-frame poll interval now follows `heartbeat_interval` (default 5s) so each refresh lands on fresh progress.
12
+ - A stalled external transform (no heartbeat within `heartbeat_stale_after`, default 60s) is now marked failed automatically — and therefore retryable — instead of hanging in "processing" forever.
13
+ - Added an `ActiveStorage::AsyncVariants.configure { |config| … }` block for setting all options in one place (see the README's Configuration section).
14
+ - Includes a migration adding the `progress` and `last_heartbeat_at` columns; run it before deploying.
15
+
1
16
  ## [0.5.0]
2
17
 
3
18
  - Added an opt-in retry affordance to the failed state: hovering a failed variant reveals a control that opens a dialog with the error and a "Retry processing" button that re-runs the transform. Disabled by default; enable it with `ActiveStorage::AsyncVariants.retry_visible_if { … }` (the block runs in the view context, so it can check `current_user`).
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
 
@@ -101,6 +101,29 @@ The resulting URL is `"#{cdn_host}/#{variant.key}"`.
101
101
 
102
102
  No manual wiring is required. The async state partials are self-contained `<turbo-frame>`s. The only requirement is that the host app loads **Turbo** -- the gem depends on `turbo-rails`, which a default Rails app already includes.
103
103
 
104
+ ## Configuration
105
+
106
+ Set options in an initializer. The `configure` block groups them (each is also a plain accessor, e.g. `ActiveStorage::AsyncVariants.cdn_host = …`):
107
+
108
+ ```ruby
109
+ # config/initializers/active_storage_async_variants.rb
110
+ ActiveStorage::AsyncVariants.configure do |config|
111
+ config.cdn_host = "https://d1234abcd.cloudfront.net"
112
+ config.heartbeat_interval = 5.seconds
113
+ config.heartbeat_stale_after = 60.seconds
114
+ config.parent_controller = "ApplicationController"
115
+ config.retry_visible_if { current_user&.admin? }
116
+ end
117
+ ```
118
+
119
+ | Option | Default | Purpose |
120
+ |--------|---------|---------|
121
+ | `cdn_host` | `nil` | Host for `direct:` URLs (`"#{cdn_host}/#{variant.key}"`); falls back to the storage service URL. |
122
+ | `heartbeat_interval` | `5.seconds` | Expected cadence of progress heartbeats; the processing `<turbo-frame>` re-polls at this rate. |
123
+ | `heartbeat_stale_after` | `60.seconds` | A processing variant with no heartbeat for this long is marked `failed`. Must exceed `heartbeat_interval`. |
124
+ | `parent_controller` | `"ActionController::Base"` | Base class for the gem's controllers, so the retry view can reach your app's `current_user`. Set as a String. |
125
+ | `retry_visible_if` | off | Block (run in the view context) gating the failed-state retry affordance. |
126
+
104
127
  ## Writing a Transformer
105
128
 
106
129
  Transformers come in two flavors: **inline** (the job blocks until processing completes) and **external** (the job kicks off remote work and a webhook signals completion).
@@ -204,31 +227,9 @@ variant.failed? # => true if permanently failed
204
227
  variant.error # => error message string, or nil
205
228
  ```
206
229
 
207
- ## Placeholder Options
208
-
209
- 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.
210
-
211
- ```ruby
212
- # Serve the original unprocessed file while processing
213
- attachable.variant :web, processing: :original
214
-
215
- # Return nil -- let the view handle it
216
- attachable.variant :web, processing: :blank
217
-
218
- # Static URL while processing
219
- attachable.variant :web, processing: "/placeholders/processing.svg"
220
-
221
- # Dynamic placeholder via Proc
222
- attachable.variant :web,
223
- processing: -> (blob) { "/placeholders/processing.svg" }
224
-
225
- # Distinct placeholder when permanently failed
226
- attachable.variant :web,
227
- processing: :original,
228
- failed: "/icons/broken.svg"
229
- ```
230
+ ## Placeholders
230
231
 
231
- 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.
232
233
 
233
234
  ## Failure Handling
234
235
 
@@ -253,7 +254,7 @@ variant.error # => "ffmpeg exited with status 1: ..."
253
254
  5. The external service processes the file, uploads the result to the destination URL
254
255
  6. The external service POSTs to the callback URL with success/failure status
255
256
  7. The gem's callback endpoint transitions the `VariantRecord` to `processed` or `failed`
256
- 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
257
258
 
258
259
  ### Inline transformer flow
259
260
 
@@ -261,7 +262,7 @@ variant.error # => "ffmpeg exited with status 1: ..."
261
262
  4. The job calls the transformer's `process` method, blocking until complete
262
263
  5. On success, the output is uploaded, the `VariantRecord` transitions to `processed`
263
264
  6. On failure, the error is recorded and the job is re-enqueued (up to 3 attempts)
264
- 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
265
266
 
266
267
  ## License
267
268
 
data/Rakefile CHANGED
@@ -13,4 +13,15 @@ end
13
13
  desc "Run rspec and cucumber"
14
14
  task all: [:spec, :cucumber]
15
15
 
16
+ namespace :vendor do
17
+ desc "Pull the latest @botandrose/progress-bar from unpkg.com into app/assets"
18
+ task :progress_bar do
19
+ require "open-uri"
20
+ url = "https://unpkg.com/@botandrose/progress-bar"
21
+ dest = File.expand_path("app/assets/javascripts/progress-bar.js", __dir__)
22
+ File.write(dest, URI.open(url).read)
23
+ puts "Vendored #{url} -> #{dest}"
24
+ end
25
+ end
26
+
16
27
  task default: :all
@@ -0,0 +1,281 @@
1
+ const CIRCLE_RADIUS = 40; // fallback r attribute; CSS shrinks it to fit the stroke
2
+ const PATH_LENGTH = 100; // normalize the arc so dash math is independent of the radius
3
+
4
+ const STYLES = `
5
+ :host {
6
+ --progress-color: #2E7D32;
7
+ --error-color: #7a242f;
8
+ --indeterminate-color: #999;
9
+ --track-color: #333333;
10
+ --progress-duration: 120ms;
11
+ --indeterminate-duration: 1.5s;
12
+ --bar-height: 32px;
13
+ --bar-padding: 8px;
14
+ --circular-size: 64px;
15
+ --circular-thickness: 16;
16
+ display: block;
17
+ overflow: hidden;
18
+ background: var(--track-color);
19
+ border: 1px solid #999;
20
+ border-radius: 4px;
21
+ min-height: var(--bar-height);
22
+ padding: var(--bar-padding);
23
+ font-size: 13px;
24
+ align-content: center;
25
+ box-sizing: border-box;
26
+ position: relative;
27
+ }
28
+ .bar {
29
+ position: absolute;
30
+ top: 0;
31
+ left: 0;
32
+ height: 100%;
33
+ width: 0%;
34
+ background: var(--progress-color);
35
+ transition: width var(--progress-duration) ease;
36
+ z-index: 1;
37
+ }
38
+ .text{position: relative; z-index: 2;}
39
+ :host([error]) .bar {
40
+ background: var(--error-color);
41
+ }
42
+
43
+ /* Indeterminate (linear): no percent set — a fixed-width segment sweeping across the track. */
44
+ :host(:not([percent])) .bar {
45
+ background: var(--indeterminate-color);
46
+ width: 40%;
47
+ animation: indeterminate-linear var(--indeterminate-duration) infinite linear;
48
+ }
49
+
50
+ @keyframes indeterminate-linear {
51
+ 0% { transform: translateX(-100%); }
52
+ 100% { transform: translateX(250%); }
53
+ }
54
+
55
+ /* Circular mode: the host box styling is for the linear track, so drop it. */
56
+ :host([mode="circular"]) {
57
+ background: transparent;
58
+ border: none;
59
+ overflow: visible;
60
+ padding: 0;
61
+ min-height: 0;
62
+ }
63
+
64
+ .circular {
65
+ position: relative;
66
+ display: inline-flex;
67
+ width: var(--circular-size);
68
+ height: var(--circular-size);
69
+ }
70
+
71
+ .ring {
72
+ width: 100%;
73
+ height: 100%;
74
+ transform: rotate(-90deg);
75
+ }
76
+
77
+ .ring circle {
78
+ fill: none;
79
+ stroke-width: var(--circular-thickness);
80
+ /* Shrink the radius so the stroke's outer edge always lands inside the
81
+ 100x100 viewBox, no matter how thick --circular-thickness is. */
82
+ r: calc(49px - var(--circular-thickness) * 0.5px);
83
+ }
84
+
85
+ .track {
86
+ stroke: var(--track-color);
87
+ }
88
+
89
+ .progress-ring {
90
+ stroke: var(--progress-color);
91
+ stroke-linecap: round;
92
+ stroke-dasharray: ${PATH_LENGTH};
93
+ stroke-dashoffset: ${PATH_LENGTH};
94
+ transition: stroke-dashoffset var(--progress-duration) ease;
95
+ }
96
+
97
+ :host(:not([percent])) .progress-ring {
98
+ stroke: var(--indeterminate-color);
99
+ }
100
+
101
+ /* The X of the error glyph; revealed by [error]. */
102
+ .error-mark {
103
+ display: none;
104
+ fill: none;
105
+ stroke: var(--error-color);
106
+ stroke-width: var(--circular-thickness);
107
+ stroke-linecap: round;
108
+ }
109
+
110
+ .label {
111
+ position: absolute;
112
+ inset: 0;
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ color: #fff;
117
+ mix-blend-mode: difference;
118
+ }
119
+
120
+ /* When the host carries its own inline styling (e.g. a color override), opt
121
+ out of the blend trick and just inherit, so the override wins predictably. */
122
+ :host([style]) .label {
123
+ mix-blend-mode: initial;
124
+ color: inherit;
125
+ }
126
+
127
+ /* Indeterminate (circular): no percent set — spin a fixed arc around the ring. */
128
+ :host(:not([percent])) .ring {
129
+ animation: indeterminate-rotate var(--indeterminate-duration) infinite linear;
130
+ }
131
+
132
+ :host(:not([percent])) .progress-ring {
133
+ stroke-dashoffset: ${PATH_LENGTH * 0.75};
134
+ }
135
+
136
+ @keyframes indeterminate-rotate {
137
+ 0% { transform: rotate(-90deg); }
138
+ 100% { transform: rotate(270deg); }
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; }
146
+ `;
147
+
148
+ const LINEAR_HTML = `
149
+ <div class="bar"></div>
150
+ <div class="text"><slot></slot></div>
151
+ `;
152
+
153
+ const CIRCULAR_HTML = `
154
+ <div class="circular">
155
+ <svg class="ring" viewBox="0 0 100 100">
156
+ <circle class="track" cx="50" cy="50" r="${CIRCLE_RADIUS}"></circle>
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>
159
+ </svg>
160
+ <span class="label"><slot></slot></span>
161
+ </div>
162
+ `;
163
+
164
+ let styleSheet = null;
165
+ function getStyleSheet() {
166
+ if (!styleSheet) {
167
+ styleSheet = new CSSStyleSheet();
168
+ styleSheet.replaceSync(STYLES);
169
+ }
170
+ return styleSheet;
171
+ }
172
+
173
+ function clampPercent(value) {
174
+ const number = Number(value);
175
+ if (Number.isNaN(number)) {
176
+ throw new TypeError(`progress-bar: percent must be numeric, got ${JSON.stringify(value)}`);
177
+ }
178
+ return Math.min(100, Math.max(0, number));
179
+ }
180
+
181
+ class ProgressBar extends HTMLElement {
182
+ constructor() {
183
+ super();
184
+ this.attachShadow({ mode: 'open' });
185
+ this._percent = null;
186
+ this._renderedMode = null;
187
+ }
188
+
189
+ connectedCallback() {
190
+ this.render();
191
+ this.setAttribute('role', 'progressbar');
192
+ this.setAttribute('aria-valuemin', '0');
193
+ this.setAttribute('aria-valuemax', '100');
194
+ this.updateBar();
195
+ }
196
+
197
+ get percent() {
198
+ return this._percent;
199
+ }
200
+
201
+ set percent(value) {
202
+ if (value === null || value === undefined) {
203
+ this.removeAttribute('percent');
204
+ } else {
205
+ this.setAttribute('percent', clampPercent(value));
206
+ }
207
+ }
208
+
209
+ get error() {
210
+ return this.hasAttribute('error');
211
+ }
212
+
213
+ set error(value) {
214
+ this.toggleAttribute('error', Boolean(value));
215
+ }
216
+
217
+ get indeterminate() {
218
+ return !this.hasAttribute('percent');
219
+ }
220
+
221
+ get mode() {
222
+ return this.getAttribute('mode') === 'circular' ? 'circular' : 'linear';
223
+ }
224
+
225
+ set mode(value) {
226
+ this.setAttribute('mode', value);
227
+ }
228
+
229
+ static get observedAttributes() {
230
+ return ['percent', 'mode'];
231
+ }
232
+
233
+ attributeChangedCallback(name, oldValue, newValue) {
234
+ if (name === 'percent') {
235
+ this._percent = newValue === null ? null : clampPercent(newValue);
236
+ }
237
+ if (name === 'mode') {
238
+ this.render();
239
+ }
240
+ this.updateBar();
241
+ }
242
+
243
+ updateBar() {
244
+ if (this.mode === 'circular') {
245
+ const ring = this.shadowRoot?.querySelector('.progress-ring');
246
+ if (ring) {
247
+ ring.style.strokeDashoffset = this.indeterminate
248
+ ? ''
249
+ : String(PATH_LENGTH * (1 - this._percent / 100));
250
+ }
251
+ } else {
252
+ const bar = this.shadowRoot?.querySelector('.bar');
253
+ if (bar) {
254
+ // Leave width to the indeterminate CSS animation when unknown.
255
+ bar.style.width = this.indeterminate ? '' : `${this._percent}%`;
256
+ }
257
+ }
258
+
259
+ if (this.indeterminate) {
260
+ this.removeAttribute('aria-valuenow');
261
+ } else {
262
+ this.setAttribute('aria-valuenow', String(this._percent));
263
+ }
264
+ }
265
+
266
+ render() {
267
+ const mode = this.mode;
268
+ if (this._renderedMode === mode) {
269
+ return;
270
+ }
271
+ this._renderedMode = mode;
272
+ this.shadowRoot.adoptedStyleSheets = [getStyleSheet()];
273
+ this.shadowRoot.innerHTML = mode === 'circular' ? CIRCULAR_HTML : LINEAR_HTML;
274
+ }
275
+ }
276
+
277
+ if (!customElements.get('progress-bar')) {
278
+ customElements.define('progress-bar', ProgressBar);
279
+ }
280
+
281
+ export default ProgressBar;
@@ -0,0 +1,35 @@
1
+ /* turbo-frame is inline by default; make it and the wrapper block-level so the
2
+ media box (and the progress bar centered on it) coincides with the parent
3
+ figure box, instead of sitting in a taller line box from the baseline gap --
4
+ otherwise an overlay centered on the figure lands off-center from the ring. */
5
+ turbo-frame:has(> .async-variant-processing),
6
+ turbo-frame:has(> .async-variant-failed) {
7
+ display: block;
8
+ }
9
+ .async-variant-processing,
10
+ .async-variant-failed {
11
+ position: relative;
12
+ display: block;
13
+ }
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. */
18
+ .async-variant-processing img,
19
+ .async-variant-processing video,
20
+ .async-variant-failed img,
21
+ .async-variant-failed video {
22
+ opacity: 0.001;
23
+ display: block;
24
+ }
25
+ .async-variant-progress {
26
+ position: absolute;
27
+ top: 50%;
28
+ left: 50%;
29
+ transform: translate(-50%, -50%);
30
+ --circular-size: 80px;
31
+ --progress-color: #2E7D32;
32
+ --indeterminate-color: #2E7D32;
33
+ --track-color: rgba(0, 0, 0, 0.25);
34
+ --error-color: #ff7c81;
35
+ }
@@ -11,6 +11,10 @@ module ActiveStorage
11
11
  when "success"
12
12
  variant_record.update!(state: "processed")
13
13
  apply_reported_metadata(variant_record, params)
14
+ when "progress"
15
+ ActiveStorage::VariantRecord
16
+ .where(id: variant_record.id, state: %w[pending processing])
17
+ .update_all(progress: params[:percent].to_i.clamp(0, 100), last_heartbeat_at: Time.current)
14
18
  when "failed"
15
19
  # error column is TEXT (64KB); utf8mb4 is up to 4 bytes/char, so cap at 16k chars.
16
20
  variant_record.update!(state: "failed", error: params[:error].to_s.truncate(16_000))
@@ -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,9 +1,15 @@
1
1
  <div class="async-variant-state async-variant-processing">
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", class: "async-variant-progress", percent: variant.progress %>
3
5
  </div>
4
- <%# Self-perpetuating poll: Turbo runs <script>s in frame swaps, so each
5
- pending/processing response schedules its own next reload. Terminal
6
- partials (_failed, _processed) emit no script -> chain stops. %>
6
+
7
+ <%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
8
+ <%= ActiveStorage::AsyncVariants.javascript_include_tag "progress-bar", type: "module" %>
9
+ <% interval_ms = ActiveStorage::AsyncVariants.heartbeat_interval.in_seconds * 1000 %>
7
10
  <script>
8
- setTimeout(() => document.getElementById("<%= async_variant_frame_id(variant) %>")?.reload(), 3000)
11
+ (() => {
12
+ const frame = document.currentScript?.closest("turbo-frame")
13
+ setTimeout(() => frame?.reload(), <%= interval_ms %>)
14
+ })()
9
15
  </script>
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddHeartbeatColumnsToVariantRecords < ActiveRecord::Migration[7.2]
4
+ def change
5
+ add_column :active_storage_variant_records, :progress, :integer, if_not_exists: true
6
+ add_column :active_storage_variant_records, :last_heartbeat_at, :datetime, if_not_exists: true
7
+ end
8
+ end
@@ -13,6 +13,23 @@ Feature: image_tag with async: true emits the right markup per variant state
13
13
  Then the page should contain a turbo-frame
14
14
  And the frame should render the processing state
15
15
 
16
+ Scenario: A processing variant renders an indeterminate progress bar before any heartbeat
17
+ Given a user with an attached avatar
18
+ And the avatar's :thumb_proc variant is in processing state
19
+ When I visit the avatar page for the :thumb_proc variant
20
+ Then the page should contain a turbo-frame
21
+ And the frame should render a progress bar
22
+ And the progress bar should be indeterminate
23
+ And the placeholder image should reserve layout
24
+
25
+ Scenario: A processing variant with reported progress renders a determinate progress bar
26
+ Given a user with an attached avatar
27
+ And the avatar's :thumb_proc variant is in processing state
28
+ And the avatar's :thumb_proc variant has reported 42% progress
29
+ When I visit the avatar page for the :thumb_proc variant
30
+ Then the page should contain a turbo-frame
31
+ And the progress bar should show 42%
32
+
16
33
  Scenario: A failed variant renders the failed partial inside the frame
17
34
  Given a user with an attached avatar
18
35
  And the retry affordance is visible to everyone
@@ -20,3 +37,4 @@ Feature: image_tag with async: true emits the right markup per variant state
20
37
  When I visit the avatar page for the :thumb_proc variant
21
38
  Then the page should contain a turbo-frame
22
39
  And the frame should render the failed state
40
+ And the frame should render an error progress bar
@@ -14,6 +14,34 @@ Given /^the avatar's :(\w+) variant is in (pending|processing|processed|failed)
14
14
  end
15
15
  end
16
16
 
17
+ Given /^the avatar's :(\w+) variant has reported (\d+)% progress$/ do |variant_name, percent|
18
+ variant = @user.avatar.variant(variant_name.to_sym)
19
+ record = variant.blob.variant_records.find_by(variation_digest: variant.variation.digest)
20
+ record.update!(progress: percent.to_i)
21
+ end
22
+
23
+ Then /^the frame should render a progress bar$/ do
24
+ expect(page).to have_css("turbo-frame progress-bar", visible: :all)
25
+ end
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
+
31
+ Then /^the progress bar should be indeterminate$/ do
32
+ expect(page).to have_css("turbo-frame progress-bar:not([percent])", visible: :all)
33
+ end
34
+
35
+ Then /^the progress bar should show (\d+)%$/ do |percent|
36
+ expect(page).to have_css(%(turbo-frame progress-bar[percent="#{percent}"]), visible: :all)
37
+ end
38
+
39
+ # Default (visible-only) matcher: passes only because the placeholder is hidden
40
+ # via opacity (not visibility/display), so Capybara still sees it.
41
+ Then /^the placeholder image should reserve layout$/ do
42
+ expect(page).to have_css("turbo-frame .async-variant-processing img")
43
+ end
44
+
17
45
  Given /^the retry affordance is visible to everyone$/ do
18
46
  ActiveStorage::AsyncVariants.retry_visible_if { true }
19
47
  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,
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ # External transforms free the worker at `initiate` and report progress via
6
+ # heartbeats; if the service dies, no terminal callback arrives and the
7
+ # record would sit in "processing" forever. This flips it to "failed" once
8
+ # the heartbeats go stale, re-arming itself each tick until then.
9
+ class HeartbeatWatchdogJob < ActiveJob::Base
10
+ # A retry destroys the record, so a re-armed job has nothing left to watch.
11
+ discard_on ActiveJob::DeserializationError
12
+
13
+ def perform(variant_record)
14
+ stale_after = ActiveStorage::AsyncVariants.heartbeat_stale_after
15
+ active = ActiveStorage::VariantRecord.where(id: variant_record.id, state: %w[pending processing])
16
+ stale = active.where("last_heartbeat_at < ?", Time.current - stale_after)
17
+
18
+ # Atomic check-and-set: a success/progress callback landing in the same
19
+ # instant moves the row out of `stale` and wins, instead of being clobbered.
20
+ marked_failed_count = stale.update_all(
21
+ state: "failed",
22
+ error: "Transcoding stalled: no heartbeat for over #{stale_after.to_i}s",
23
+ )
24
+
25
+ if marked_failed_count.positive?
26
+ touch_consumers(variant_record)
27
+ elsif active.any?
28
+ self.class.set(wait: stale_after).perform_later(variant_record)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # update_all skips the terminal-state touch, so invalidate consumer caches by hand.
35
+ def touch_consumers(variant_record)
36
+ variant_record.blob.attachments.includes(:record).each { |a| a.record&.touch }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -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
@@ -52,18 +52,30 @@ module ActiveStorage
52
52
  find_preview_variant_record&.state || "pending"
53
53
  end
54
54
 
55
+ def progress
56
+ find_preview_variant_record&.progress
57
+ end
58
+
59
+ def progress_known?
60
+ !find_preview_variant_record&.progress.nil?
61
+ end
62
+
63
+ def last_heartbeat_at
64
+ find_preview_variant_record&.last_heartbeat_at
65
+ end
66
+
55
67
  private
56
68
 
57
69
  def async_preview?
58
- resolved_async_options[:transformer].present?
70
+ resolved_async_options[:async].present?
59
71
  end
60
72
 
61
73
  # Variations rebuilt from the redirect URL only carry transformations --
62
- # :transformer / :processing / :failed are stripped at Variation#initialize
63
- # and not embedded in the URL key. Recover them via the digest-keyed
64
- # registry that VariationExtension warms on every view-side variant call,
65
- # or fall back to scanning attached named variants when the registry is
66
- # 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).
67
79
  def resolved_async_options
68
80
  @resolved_async_options ||=
69
81
  variation.async_options.presence ||
@@ -78,7 +90,7 @@ module ActiveStorage
78
90
  attachment.send(:named_variants).each do |name, _|
79
91
  candidate = attachment.variant(name.to_sym)
80
92
  next unless candidate.variation.transformations.to_json == target
81
- 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]
82
94
  end
83
95
  end
84
96
  nil
@@ -95,25 +107,9 @@ module ActiveStorage
95
107
  blob.variant_records.find_by(variation_digest: variation.digest)
96
108
  end
97
109
 
110
+ # Serve the original until the preview variant is processed.
98
111
  def fallback_preview_url(...)
99
- case active_fallback
100
- when :original then blob.url(...)
101
- when :blank then nil
102
- when Proc then active_fallback.call(blob)
103
- when String then active_fallback
104
- end
105
- end
106
-
107
- def active_fallback
108
- if failed?
109
- resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
110
- else
111
- resolved_async_options[:processing]
112
- end
113
- end
114
-
115
- def failed?
116
- find_preview_variant_record&.state == "failed"
112
+ blob.url(...)
117
113
  end
118
114
  end
119
115
  end
@@ -79,6 +79,13 @@ module ActiveStorage
79
79
  variant_record_id: variant_record.id,
80
80
  **options,
81
81
  )
82
+
83
+ # Seed the heartbeat so a transform that dies before its first heartbeat
84
+ # still goes stale, then arm the watchdog.
85
+ variant_record.touch(:last_heartbeat_at)
86
+ ActiveStorage::AsyncVariants::HeartbeatWatchdogJob
87
+ .set(wait: ActiveStorage::AsyncVariants.heartbeat_stale_after)
88
+ .perform_later(variant_record)
82
89
  end
83
90
  end
84
91
  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
@@ -70,8 +63,24 @@ module ActiveStorage
70
63
  async_record&.state || "pending"
71
64
  end
72
65
 
66
+ def progress
67
+ async_record&.progress
68
+ end
69
+
70
+ def progress_known?
71
+ !async_record&.progress.nil?
72
+ end
73
+
74
+ def last_heartbeat_at
75
+ async_record&.last_heartbeat_at
76
+ end
77
+
73
78
  private
74
79
 
80
+ def async_variant?
81
+ resolved_async_options[:async].present?
82
+ end
83
+
75
84
  def resolved_async_options
76
85
  @resolved_async_options ||=
77
86
  variation.async_options.presence ||
@@ -80,14 +89,6 @@ module ActiveStorage
80
89
  {}
81
90
  end
82
91
 
83
- def active_fallback
84
- if failed?
85
- resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
86
- else
87
- resolved_async_options[:processing]
88
- end
89
- end
90
-
91
92
  # Cold-path scan: used by enqueue! (which needs the attachment +
92
93
  # variant_name to dispatch ProcessJob) and by resolved_async_options as
93
94
  # a fallback when the Registry is cold (e.g. in dev, when a
@@ -114,7 +115,7 @@ module ActiveStorage
114
115
  attachment.send(:named_variants).each do |name, _|
115
116
  candidate = attachment.variant(name.to_sym)
116
117
  if candidate.variation.transformations.to_json == target
117
- return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:processing].present?
118
+ return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
118
119
  end
119
120
  end
120
121
  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.5.0"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
@@ -14,6 +14,7 @@ require_relative "async_variants/preview_extension"
14
14
  require_relative "async_variants/attachment_extension"
15
15
  require_relative "async_variants/reflection_extension"
16
16
  require_relative "async_variants/process_job"
17
+ require_relative "async_variants/heartbeat_watchdog_job"
17
18
  require_relative "async_variants/asset_tag_helper_extension"
18
19
 
19
20
  module ActiveStorage
@@ -30,6 +31,14 @@ module ActiveStorage
30
31
  # the host's class is autoloadable. Defaults to ActionController::Base.
31
32
  mattr_accessor :parent_controller, default: "ActionController::Base"
32
33
 
34
+ # How long an external transform may go without a heartbeat before the
35
+ # watchdog fails it. Must exceed the transformer's heartbeat interval.
36
+ mattr_accessor :heartbeat_stale_after, default: 60.seconds
37
+
38
+ # The transformer's heartbeat cadence. The turbo-frame poll matches it so
39
+ # each reload lands on fresh progress; keep heartbeat_stale_after well above it.
40
+ mattr_accessor :heartbeat_interval, default: 5.seconds
41
+
33
42
  # Gates the failed-state retry affordance; the block runs in the view context.
34
43
  def self.retry_visible_if(&block)
35
44
  self.retry_visible_proc = block
@@ -41,6 +50,10 @@ module ActiveStorage
41
50
  false
42
51
  end
43
52
 
53
+ def self.configure
54
+ yield self
55
+ end
56
+
44
57
  class Engine < ::Rails::Engine
45
58
  # Prepend the core model/reflection extensions before eager_load runs
46
59
  # so that models' has_X_attached blocks (and the Variation.wrap calls
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.5.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-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
@@ -66,7 +66,9 @@ files:
66
66
  - LICENSE.txt
67
67
  - README.md
68
68
  - Rakefile
69
+ - app/assets/javascripts/progress-bar.js
69
70
  - app/assets/javascripts/retry.js
71
+ - app/assets/stylesheets/progress.css
70
72
  - app/assets/stylesheets/retry.css
71
73
  - app/controllers/active_storage/async_variants/callbacks_controller.rb
72
74
  - app/controllers/active_storage/async_variants/states_controller.rb
@@ -76,6 +78,7 @@ files:
76
78
  - app/views/active_storage/async_variants/states/_processing.html.erb
77
79
  - app/views/active_storage/async_variants/states/show.html.erb
78
80
  - config/routes.rb
81
+ - db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb
79
82
  - features/retry_flow.feature
80
83
  - features/retry_visibility.feature
81
84
  - features/state_rendering.feature
@@ -88,6 +91,7 @@ files:
88
91
  - lib/active_storage/async_variants/asset_tag_helper_extension.rb
89
92
  - lib/active_storage/async_variants/attachment_extension.rb
90
93
  - lib/active_storage/async_variants/blob_extension.rb
94
+ - lib/active_storage/async_variants/heartbeat_watchdog_job.rb
91
95
  - lib/active_storage/async_variants/helper.rb
92
96
  - lib/active_storage/async_variants/preview_extension.rb
93
97
  - lib/active_storage/async_variants/process_job.rb