active_storage-async_variants 0.1.0 → 0.4.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/.github/workflows/ci.yml +0 -3
- data/CHANGELOG.md +21 -1
- data/CLAUDE.md +4 -4
- data/README.md +67 -27
- data/Rakefile +9 -1
- data/app/controllers/active_storage/async_variants/callbacks_controller.rb +34 -1
- data/app/controllers/active_storage/async_variants/states_controller.rb +46 -0
- data/app/views/active_storage/async_variants/states/_failed.html.erb +4 -0
- data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
- data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
- data/app/views/active_storage/async_variants/states/_processing.html.erb +10 -0
- data/app/views/active_storage/async_variants/states/show.html.erb +8 -0
- data/config/routes.rb +4 -0
- data/features/state_rendering.feature +21 -0
- data/features/step_definitions/dummy_steps.rb +116 -0
- data/features/support/env.rb +28 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +62 -0
- data/lib/active_storage/async_variants/attachment_extension.rb +3 -1
- data/lib/active_storage/async_variants/blob_extension.rb +15 -0
- data/lib/active_storage/async_variants/helper.rb +59 -0
- data/lib/active_storage/async_variants/preview_extension.rb +120 -0
- data/lib/active_storage/async_variants/process_job.rb +6 -7
- data/lib/active_storage/async_variants/reflection_extension.rb +35 -0
- data/lib/active_storage/async_variants/registry.rb +38 -0
- data/lib/active_storage/async_variants/variant_record_extension.rb +29 -0
- data/lib/active_storage/async_variants/variant_with_record_extension.rb +88 -19
- data/lib/active_storage/async_variants/variation_extension.rb +2 -1
- data/lib/active_storage/async_variants/version.rb +1 -1
- data/lib/active_storage/async_variants.rb +66 -9
- metadata +32 -5
- data/gemfiles/rails_7.2.gemfile.lock +0 -269
- data/gemfiles/rails_8.0.gemfile.lock +0 -266
- data/gemfiles/rails_8.1.gemfile.lock +0 -269
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 003cbfe68ded6c238669546679e76b7d0c8e856842450181ceb8497a9e452361
|
|
4
|
+
data.tar.gz: 0b41dad0aa8a59e81e9b2e384d6d04e55ee479c0c35822d71e3a06a46b0c64cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5eb0728baf9f4d9cfc591625f6035d362d9df192c2c64fb7cef4e397fcef9817a460dbfba24210f01d777360a1d2e73e4dabf7d00306f8c9685ff1df4641b0e
|
|
7
|
+
data.tar.gz: 4c87c749a9dad93f251fcddf5830996f53d7c054becabdd529ea89000eb75e5f2360b6332aea764fc615931b0c350a15bfe8610d4b5efa1561a22ffd3d966ee6
|
data/.github/workflows/ci.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [0.4.0]
|
|
2
|
+
|
|
3
|
+
- **Breaking:** Replaced the polling-`<img>` architecture with a `<turbo-frame>` for unprocessed variants. `image_tag`/`video_tag` with `async: true` now emits a normal `<img>`/`<video>` when the variant is already processed, and a `<turbo-frame src="…">` otherwise. The frame hits a new gem-shipped endpoint (`GET /active_storage/async_variants/states/:signed_blob_id/:variation_key`) that renders one of `_processing`/`_failed`/`_processed` partials. Non-terminal renders include an inline `<script>` that schedules the frame's next reload, so the state polls itself until it terminates.
|
|
4
|
+
- New `app/views/active_storage/async_variants/states/` partial set — apps can override any partial by creating a same-named file in their own `app/views/active_storage/async_variants/states/`. The processing state defaults to a bare `<img>`/`<video>` pointing at the variant's URL, which the gem redirects to the app's configured `processing:` SVG.
|
|
5
|
+
- All CSS and JavaScript ship inline in the per-response state partials — no asset-pipeline footprint. Apps don't need to include any stylesheet or script.
|
|
6
|
+
- **Breaking:** `Variant#processed` and `Preview#processed` are now no-ops on bucket-backed services (they previously also lazy-enqueued `ProcessJob` for any record that wasn't already processed). Enqueue now happens only at attachment time (via `AttachmentExtension#transform_variants_later`). Pre-existing blobs that missed the auto-enqueue won't self-heal on first view; backfill via a rake task.
|
|
7
|
+
- New public `#enqueue!` method on both `Variant` and `Preview` with the same signature — no `respond_to?` dance. Creates a pending `VariantRecord` (RecordNotUnique guards dedupe) and dispatches `ProcessJob`. Replaces the previously private `enqueue_processing` / `enqueue_async_preview`.
|
|
8
|
+
- The named-variant lookup now walks one level back through `preview_image` attachments, so a request for a Variant of a video's extracted preview frame can recover the named-variant declaration from the parent record's source-video field. Fixes nil-URL 500s in dev where the cold Registry can't resolve a redirect-controller request.
|
|
9
|
+
- Added `turbo-rails >= 2.0` as a runtime dependency.
|
|
10
|
+
- Removed `RepresentationsRedirectControllerExtension`, the `[data-async-variant-*]` polling JS, the bundled `app/assets/stylesheets/active_storage_async_variants.css`, the engine's asset precompile initializer, and the `apply_async_data!` helper machinery. Consumers depending on those data attributes must migrate to targeting the new `.async-variant-state` classes or override the partials.
|
|
11
|
+
|
|
12
|
+
## [0.3.1]
|
|
13
|
+
|
|
14
|
+
- Bound the stored variant `error` to 16k chars at both write sites (the failed-status callback and `ProcessJob`'s rescue). An external transformer reporting a >64KB error payload was overflowing the `TEXT` column and 500ing the callback, leaving the variant stuck instead of marked failed.
|
|
15
|
+
|
|
16
|
+
## [0.3.0]
|
|
17
|
+
|
|
18
|
+
- Touch attached records when a variant transitions to processed, so consumer caches keyed on `cache_key_with_version` invalidate without needing manual cascading. Multi-hop cascades remain the consumer's responsibility via standard Rails `touch:` or `after_touch`.
|
|
19
|
+
- Dedupe concurrent `.processed` calls so they enqueue at most one `ProcessJob` per blob+variation. Previously, every call before the first job flipped state to "processing" enqueued another job; each job created a fresh variant blob, leaving the others as orphans. The record is now created as `pending` at enqueue time, using the unique index on `active_storage_variant_records (blob_id, variation_digest)` (already shipped by the standard Active Storage migration) as the dedupe key. Once a record reaches `failed` (after `ProcessJob` exhausts its 3-attempt `retry_on` cycle), further `.processed` calls no longer re-enqueue — the variant is permanently failed.
|
|
20
|
+
- Renamed the `fallback:` variant option to `processing:` to better describe what it represents — the placeholder served while the variant is being processed.
|
|
21
|
+
- New `failed:` variant option that specifies a distinct placeholder to render when the variant has permanently failed, separate from the `processing:` placeholder. Accepts `:original`, `:blank`, a String URL, or a Proc that receives the blob. Defaults to `processing:` when unspecified. Also extends `processing:` to accept a String URL directly (previously you had to wrap a static URL in a Proc).
|
|
2
22
|
|
|
3
23
|
## [0.1.0] - 2026-03-03
|
|
4
24
|
|
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 `processing:` 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
|
|
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?`)
|
|
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 `processing:` to any named variant to opt into the async pipeline. The value is what to serve while the variant is being processed:
|
|
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
|
+
processing: :original
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
The presence of `
|
|
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:
|
|
37
37
|
|
|
38
38
|
```ruby
|
|
39
39
|
has_one_attached :video do |attachable|
|
|
@@ -41,14 +41,14 @@ has_one_attached :video do |attachable|
|
|
|
41
41
|
attachable.variant :web,
|
|
42
42
|
transformer: VideoTranscoder,
|
|
43
43
|
codec: "vp9",
|
|
44
|
-
|
|
44
|
+
processing: :original
|
|
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
|
+
processing: :original
|
|
50
50
|
|
|
51
|
-
# Sync with custom transformer (fast custom processing, no
|
|
51
|
+
# Sync with custom transformer (fast custom processing, no opt-in needed)
|
|
52
52
|
attachable.variant :watermarked,
|
|
53
53
|
transformer: WatermarkStamper
|
|
54
54
|
end
|
|
@@ -62,6 +62,45 @@ In views, use the same Active Storage helpers:
|
|
|
62
62
|
|
|
63
63
|
If the variant is still processing, this serves the original video. Once processing completes, it serves the transcoded variant.
|
|
64
64
|
|
|
65
|
+
## `image_tag` / `video_tag` with `async:` and `direct:`
|
|
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`:
|
|
68
|
+
|
|
69
|
+
```erb
|
|
70
|
+
<%# Variant URL with the async client wired up: spinner while pending,
|
|
71
|
+
polls for completion, swaps to the real image when ready. %>
|
|
72
|
+
<%= image_tag user.avatar.variant(:web), async: true %>
|
|
73
|
+
|
|
74
|
+
<%# When the variant is ready, render its direct CDN/S3 URL instead of
|
|
75
|
+
routing through Rails. While pending/failed, falls back to the
|
|
76
|
+
Rails representation URL (which serves the placeholder). %>
|
|
77
|
+
<%= image_tag user.avatar.variant(:web), direct: true %>
|
|
78
|
+
|
|
79
|
+
<%# Both together: direct URL once processed, placeholder while pending,
|
|
80
|
+
polling for completion. %>
|
|
81
|
+
<%= image_tag user.avatar.variant(:web), async: true, direct: true %>
|
|
82
|
+
|
|
83
|
+
<%# Same for videos. %>
|
|
84
|
+
<%= video_tag user.video.variant(:web), async: true, controls: true %>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The first argument must be a `VariantWithRecord` or `Preview` when either option is set; otherwise `image_tag` / `video_tag` behave exactly as in stock Rails.
|
|
88
|
+
|
|
89
|
+
### Configure the direct URL host
|
|
90
|
+
|
|
91
|
+
By default `direct:` uses the storage service's URL (presigned for private buckets, unsigned for public). To serve from a CDN, set the host in an initializer:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
# config/initializers/active_storage_async_variants.rb
|
|
95
|
+
ActiveStorage::AsyncVariants.cdn_host = "https://d1234abcd.cloudfront.net"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The resulting URL is `"#{cdn_host}/#{variant.key}"`.
|
|
99
|
+
|
|
100
|
+
### JavaScript
|
|
101
|
+
|
|
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
|
+
|
|
65
104
|
## Writing a Transformer
|
|
66
105
|
|
|
67
106
|
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).
|
|
@@ -158,41 +197,42 @@ The gem determines the mode by which method the transformer implements: `initiat
|
|
|
158
197
|
```ruby
|
|
159
198
|
variant = user.video.variant(:web)
|
|
160
199
|
|
|
161
|
-
variant.
|
|
200
|
+
variant.processed? # => true if processed successfully
|
|
162
201
|
variant.processing? # => true if job is running or external service is working
|
|
163
202
|
variant.pending? # => true if job is enqueued
|
|
164
203
|
variant.failed? # => true if permanently failed
|
|
165
204
|
variant.error # => error message string, or nil
|
|
166
205
|
```
|
|
167
206
|
|
|
168
|
-
##
|
|
207
|
+
## Placeholder Options
|
|
169
208
|
|
|
170
|
-
The `
|
|
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.
|
|
171
210
|
|
|
172
211
|
```ruby
|
|
173
|
-
# Serve the original unprocessed file
|
|
174
|
-
attachable.variant :web,
|
|
212
|
+
# Serve the original unprocessed file while processing
|
|
213
|
+
attachable.variant :web, processing: :original
|
|
175
214
|
|
|
176
215
|
# Return nil -- let the view handle it
|
|
177
|
-
attachable.variant :web,
|
|
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" }
|
|
178
224
|
|
|
179
|
-
#
|
|
225
|
+
# Distinct placeholder when permanently failed
|
|
180
226
|
attachable.variant :web,
|
|
181
|
-
|
|
227
|
+
processing: :original,
|
|
228
|
+
failed: "/icons/broken.svg"
|
|
182
229
|
```
|
|
183
230
|
|
|
184
|
-
|
|
231
|
+
Both options accept the same set of values: `:original`, `:blank`, a String URL, or a Proc that receives the blob.
|
|
185
232
|
|
|
186
|
-
|
|
233
|
+
## Failure Handling
|
|
187
234
|
|
|
188
|
-
|
|
189
|
-
attachable.variant :web,
|
|
190
|
-
transformer: VideoTranscoder,
|
|
191
|
-
codec: "vp9",
|
|
192
|
-
resolution: "720p",
|
|
193
|
-
fallback: :original,
|
|
194
|
-
max_retries: 5
|
|
195
|
-
```
|
|
235
|
+
A variant is retried up to 3 times before being marked as permanently failed. Once permanently failed, `.processed` no longer re-enqueues the job — the variant stays failed until you delete the `ActiveStorage::VariantRecord` row manually (or re-attach a fresh blob).
|
|
196
236
|
|
|
197
237
|
Inspect failures:
|
|
198
238
|
|
|
@@ -213,15 +253,15 @@ variant.error # => "ffmpeg exited with status 1: ..."
|
|
|
213
253
|
5. The external service processes the file, uploads the result to the destination URL
|
|
214
254
|
6. The external service POSTs to the callback URL with success/failure status
|
|
215
255
|
7. The gem's callback endpoint transitions the `VariantRecord` to `processed` or `failed`
|
|
216
|
-
8. When a view requests the variant URL, the gem checks state and serves the variant or the
|
|
256
|
+
8. When a view requests the variant URL, the gem checks state and serves the variant or the `processing:`/`failed:` placeholder
|
|
217
257
|
|
|
218
258
|
### Inline transformer flow
|
|
219
259
|
|
|
220
260
|
1-3. Same as above
|
|
221
261
|
4. The job calls the transformer's `process` method, blocking until complete
|
|
222
262
|
5. On success, the output is uploaded, the `VariantRecord` transitions to `processed`
|
|
223
|
-
6. On failure, the error is recorded and the job is re-enqueued (up to
|
|
224
|
-
7. When a view requests the variant URL, the gem checks state and serves the variant or the
|
|
263
|
+
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
|
|
225
265
|
|
|
226
266
|
## License
|
|
227
267
|
|
data/Rakefile
CHANGED
|
@@ -5,4 +5,12 @@ require "rspec/core/rake_task"
|
|
|
5
5
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
desc "Run browser-level acceptance tests (cucumber + cuprite against spec/dummy)"
|
|
9
|
+
task :cucumber do
|
|
10
|
+
sh "bundle exec cucumber"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "Run rspec and cucumber"
|
|
14
|
+
task all: [:spec, :cucumber]
|
|
15
|
+
|
|
16
|
+
task default: :all
|
|
@@ -10,8 +10,10 @@ module ActiveStorage
|
|
|
10
10
|
case params[:status]
|
|
11
11
|
when "success"
|
|
12
12
|
variant_record.update!(state: "processed")
|
|
13
|
+
apply_reported_metadata(variant_record, params)
|
|
13
14
|
when "failed"
|
|
14
|
-
|
|
15
|
+
# error column is TEXT (64KB); utf8mb4 is up to 4 bytes/char, so cap at 16k chars.
|
|
16
|
+
variant_record.update!(state: "failed", error: params[:error].to_s.truncate(16_000))
|
|
15
17
|
else
|
|
16
18
|
head :unprocessable_entity and return
|
|
17
19
|
end
|
|
@@ -20,6 +22,37 @@ module ActiveStorage
|
|
|
20
22
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
21
23
|
head :unauthorized
|
|
22
24
|
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# External transformers (Crucible) write the file directly to the bucket
|
|
29
|
+
# and report its byte_size/checksum on the success callback. Reconcile the
|
|
30
|
+
# placeholder blobs created with byte_size: 0, checksum: "0" -- the variant
|
|
31
|
+
# itself, and (for video previews) the extracted frame on the source blob.
|
|
32
|
+
def apply_reported_metadata(variant_record, params)
|
|
33
|
+
reconcile(variant_record.image.blob, params[:byte_size], params[:checksum])
|
|
34
|
+
reconcile(variant_record.blob, params[:preview_image_byte_size], params[:preview_image_checksum])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The placeholder sentinels (byte_size 0, checksum "0") gate each field, so
|
|
38
|
+
# this is idempotent and never overwrites a real source blob's metadata.
|
|
39
|
+
def reconcile(blob, byte_size, checksum)
|
|
40
|
+
return unless blob
|
|
41
|
+
|
|
42
|
+
attrs = {}
|
|
43
|
+
if (bytes = positive_int(byte_size)) && blob.byte_size.zero?
|
|
44
|
+
attrs[:byte_size] = bytes
|
|
45
|
+
end
|
|
46
|
+
if checksum.present? && checksum != "0" && blob.checksum == "0"
|
|
47
|
+
attrs[:checksum] = checksum
|
|
48
|
+
end
|
|
49
|
+
blob.update!(attrs) if attrs.any?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def positive_int(value)
|
|
53
|
+
int = value.to_i
|
|
54
|
+
int.positive? ? int : nil
|
|
55
|
+
end
|
|
23
56
|
end
|
|
24
57
|
end
|
|
25
58
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
class StatesController < ActiveStorage::AsyncVariants.parent_controller.constantize
|
|
6
|
+
# signed_blob_id + variation_key in URL act as CSRF: both require app's secret.
|
|
7
|
+
skip_forgery_protection
|
|
8
|
+
|
|
9
|
+
helper "turbo/frames"
|
|
10
|
+
helper ActiveStorage::AsyncVariants::Helper
|
|
11
|
+
|
|
12
|
+
layout false
|
|
13
|
+
|
|
14
|
+
before_action :set_variant
|
|
15
|
+
|
|
16
|
+
def show
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
helper_method :async_variant_kind, :async_variant_direct?, :async_variant_html_options
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def set_variant
|
|
24
|
+
blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id])
|
|
25
|
+
variation = ActiveStorage::Variation.decode(params[:variation_key])
|
|
26
|
+
@variant = blob.variant(variation)
|
|
27
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
28
|
+
head :not_found
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def async_variant_kind
|
|
32
|
+
params[:kind].to_s == "video" ? :video : :image
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def async_variant_direct?
|
|
36
|
+
ActiveModel::Type::Boolean.new.cast(params[:direct])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def async_variant_html_options
|
|
40
|
+
params[:opts]
|
|
41
|
+
&.permit(*ActiveStorage::AsyncVariants::PASS_THROUGH_HTML_OPTIONS)
|
|
42
|
+
.to_h
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= render "active_storage/async_variants/states/processing", local_assigns %>
|
|
@@ -0,0 +1,10 @@
|
|
|
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 %>
|
|
4
|
+
</div>
|
|
5
|
+
<%# Self-perpetuating poll: Turbo runs <script>s in frame swaps, so each
|
|
6
|
+
pending/processing response schedules its own next reload. Terminal
|
|
7
|
+
partials (_failed, _processed) emit no script -> chain stops. %>
|
|
8
|
+
<script>
|
|
9
|
+
setTimeout(() => document.getElementById("<%= async_variant_frame_id(variant) %>")?.reload(), 3000)
|
|
10
|
+
</script>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%= turbo_frame_tag async_variant_frame_id(@variant) do %>
|
|
2
|
+
<%= render "active_storage/async_variants/states/#{@variant.async_state}", {
|
|
3
|
+
variant: @variant,
|
|
4
|
+
html_options: async_variant_html_options,
|
|
5
|
+
kind: async_variant_kind,
|
|
6
|
+
direct: async_variant_direct?,
|
|
7
|
+
} %>
|
|
8
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -4,4 +4,8 @@ Rails.application.routes.draw do
|
|
|
4
4
|
post "/active_storage/async_variants/callbacks/:token",
|
|
5
5
|
to: "active_storage/async_variants/callbacks#create",
|
|
6
6
|
as: :active_storage_async_variant_callback
|
|
7
|
+
|
|
8
|
+
get "/active_storage/async_variants/states/:signed_blob_id/:variation_key",
|
|
9
|
+
to: "active_storage/async_variants/states#show",
|
|
10
|
+
as: :async_variant_state
|
|
7
11
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Feature: image_tag with async: true emits the right markup per variant state
|
|
2
|
+
|
|
3
|
+
Scenario: A processed variant emits a plain <img> -- no turbo-frame, no chrome
|
|
4
|
+
Given a user with an attached avatar
|
|
5
|
+
And the avatar's :thumb_proc variant is in processed state
|
|
6
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
7
|
+
Then the page should NOT contain a turbo-frame
|
|
8
|
+
|
|
9
|
+
Scenario: An unprocessed variant emits a turbo-frame in processing state
|
|
10
|
+
Given a user with an attached avatar
|
|
11
|
+
And the avatar's :thumb_proc variant is in processing state
|
|
12
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
13
|
+
Then the page should contain a turbo-frame
|
|
14
|
+
And the frame should render the processing state
|
|
15
|
+
|
|
16
|
+
Scenario: A failed variant renders the failed partial inside the frame
|
|
17
|
+
Given a user with an attached avatar
|
|
18
|
+
And the avatar's :thumb_proc variant is in failed state with error "boom"
|
|
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 the failed state
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Given /^a user with an attached avatar$/ do
|
|
4
|
+
@user = User.create!
|
|
5
|
+
attach_avatar_to(@user)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
Given /^the avatar's :(\w+) variant is in (pending|processing|processed|failed) state(?: with error "([^"]+)")?$/ do |variant_name, state, error|
|
|
9
|
+
variant = @user.avatar.variant(variant_name.to_sym)
|
|
10
|
+
if state == "processed"
|
|
11
|
+
simulate_processed_variant(variant)
|
|
12
|
+
else
|
|
13
|
+
create_variant_record(variant, state: state, error: error)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Given /^the retry affordance is visible to everyone$/ do
|
|
18
|
+
ActiveStorage::AsyncVariants.retry_visible_if { true }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Given /^the retry affordance is hidden$/ do
|
|
22
|
+
ActiveStorage::AsyncVariants.retry_visible_if { false }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Given /^the retry affordance requires a signed-in user$/ do
|
|
26
|
+
ActiveStorage::AsyncVariants.retry_visible_if { current_user.present? }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
When /^I sign in as the (admin|user)$/ do |_kind|
|
|
30
|
+
visit "/session/#{@user.id}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
When /^I visit the avatar page for the :(\w+) variant$/ do |variant_name|
|
|
34
|
+
visit "/avatars/#{@user.id}/#{variant_name}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Then /^the page should contain a turbo-frame$/ do
|
|
38
|
+
expect(page).to have_css("turbo-frame")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Then /^the page should NOT contain a turbo-frame$/ do
|
|
42
|
+
expect(page).to have_no_css("turbo-frame")
|
|
43
|
+
expect(page).to have_css("img")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Then /^the frame should render the (failed|processing|processed) state$/ do |state|
|
|
47
|
+
expect(page).to have_css("turbo-frame .async-variant-#{state}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Then /^the retry affordance should be visible$/ do
|
|
51
|
+
expect(page).to have_css("async-variant-retry", visible: :all)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Then /^the retry affordance should NOT be visible$/ do
|
|
55
|
+
expect(page).to have_no_css("async-variant-retry", visible: :all)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Then /^the page should not have navigated away$/ do
|
|
59
|
+
expect(page).to have_css("h1#page-marker")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Then /^the failure dialog should be open$/ do
|
|
63
|
+
expect(page).to have_css("async-variant-retry", visible: :all)
|
|
64
|
+
open = page.evaluate_script(<<~JS)
|
|
65
|
+
(() => {
|
|
66
|
+
const host = document.querySelector("async-variant-retry")
|
|
67
|
+
if (!host || !host.shadowRoot) return false
|
|
68
|
+
const dialog = host.shadowRoot.querySelector("dialog")
|
|
69
|
+
return dialog ? dialog.open : false
|
|
70
|
+
})()
|
|
71
|
+
JS
|
|
72
|
+
expect(open).to eq(true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Then /^the failure dialog should contain "([^"]+)"$/ do |text|
|
|
76
|
+
contains = page.evaluate_script(<<~JS)
|
|
77
|
+
(() => {
|
|
78
|
+
const host = document.querySelector("async-variant-retry")
|
|
79
|
+
if (!host || !host.shadowRoot) return false
|
|
80
|
+
return host.shadowRoot.textContent.includes(#{text.inspect})
|
|
81
|
+
})()
|
|
82
|
+
JS
|
|
83
|
+
expect(contains).to eq(true)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Wait for Turbo to swap the frame and our shim to attach the shadow root.
|
|
87
|
+
def wait_for_shadow!
|
|
88
|
+
Timeout.timeout(5) do
|
|
89
|
+
loop do
|
|
90
|
+
attached = page.evaluate_script("!!document.querySelector('async-variant-retry')?.shadowRoot")
|
|
91
|
+
break if attached
|
|
92
|
+
sleep 0.05
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
When /^I click the retry opener$/ do
|
|
98
|
+
wait_for_shadow!
|
|
99
|
+
page.evaluate_script(<<~JS)
|
|
100
|
+
document.querySelector("async-variant-retry").shadowRoot.querySelector(".opener").click()
|
|
101
|
+
JS
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
When /^I click "Retry processing"$/ do
|
|
105
|
+
wait_for_shadow!
|
|
106
|
+
page.evaluate_script(<<~JS)
|
|
107
|
+
document.querySelector("async-variant-retry").shadowRoot.querySelector("footer button").click()
|
|
108
|
+
JS
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
When /^I close the failure dialog$/ do
|
|
112
|
+
wait_for_shadow!
|
|
113
|
+
page.evaluate_script(<<~JS)
|
|
114
|
+
document.querySelector("async-variant-retry").shadowRoot.querySelector("dialog > button[type=button]").click()
|
|
115
|
+
JS
|
|
116
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
4
|
+
require File.expand_path("../../spec/dummy/config/environment", __dir__)
|
|
5
|
+
|
|
6
|
+
require "capybara"
|
|
7
|
+
require "capybara/cucumber"
|
|
8
|
+
require "capybara/cuprite"
|
|
9
|
+
require_relative "../../spec/support/schema"
|
|
10
|
+
|
|
11
|
+
DummySchema.load!
|
|
12
|
+
|
|
13
|
+
Capybara.register_driver(:cuprite) do |app|
|
|
14
|
+
Capybara::Cuprite::Driver.new(app, window_size: [1200, 900], timeout: 10, process_timeout: 20, js_errors: true)
|
|
15
|
+
end
|
|
16
|
+
Capybara.app = Rails.application
|
|
17
|
+
Capybara.javascript_driver = :cuprite
|
|
18
|
+
Capybara.default_driver = :cuprite
|
|
19
|
+
Capybara.server = :puma, { Silent: true }
|
|
20
|
+
|
|
21
|
+
ActionController::Base.allow_forgery_protection = false
|
|
22
|
+
|
|
23
|
+
# Test adapter just records enqueued jobs -- we don't want them to actually run
|
|
24
|
+
# during browser tests (they'd call real transformers and modify variant_records
|
|
25
|
+
# behind the test's back). The retry step seeds the new state explicitly.
|
|
26
|
+
ActiveJob::Base.queue_adapter = :test
|
|
27
|
+
|
|
28
|
+
Before { DummySchema.cleanup! }
|
data/gemfiles/rails_7.2.gemfile
CHANGED
data/gemfiles/rails_8.0.gemfile
CHANGED
data/gemfiles/rails_8.1.gemfile
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
# Prepended onto ActionView::Helpers::AssetTagHelper. Adds `async:` and
|
|
6
|
+
# `direct:` options to image_tag/video_tag; routes unprocessed variants
|
|
7
|
+
# through a <turbo-frame> whose body lives in StatesController#show.
|
|
8
|
+
module AssetTagHelperExtension
|
|
9
|
+
include ActiveStorage::AsyncVariants::Helper
|
|
10
|
+
|
|
11
|
+
def image_tag(source, options = {})
|
|
12
|
+
async_variant_tag(:image, [source], options) do |urls, opts|
|
|
13
|
+
super(urls.first, opts)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def video_tag(*sources)
|
|
18
|
+
options = sources.extract_options!
|
|
19
|
+
async_variant_tag(:video, sources, options) do |urls, opts|
|
|
20
|
+
super(*urls, opts)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def async_variant_tag(kind, sources, options)
|
|
27
|
+
options = options.symbolize_keys
|
|
28
|
+
async = options.delete(:async)
|
|
29
|
+
direct = options.delete(:direct)
|
|
30
|
+
return yield(sources, options) if !async && !direct
|
|
31
|
+
|
|
32
|
+
variant, *rest = sources
|
|
33
|
+
assert_async_variant!(variant)
|
|
34
|
+
|
|
35
|
+
if async && !async_variant_processed_inline?(variant)
|
|
36
|
+
async_variant_turbo_frame(variant, kind:, direct:, html_options: options) do
|
|
37
|
+
# populate TurboFrame with a placeholder image/video, so there's valid markup right away
|
|
38
|
+
yield [async_variant_representation_path(variant), *rest], options.except(:data)
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
yield [async_variant_resolved_src(variant, direct:), *rest], options
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def assert_async_variant!(source)
|
|
46
|
+
unless source.is_a?(ActiveStorage::VariantWithRecord) || source.is_a?(ActiveStorage::Preview)
|
|
47
|
+
raise ArgumentError, "image_tag/video_tag with async:/direct: requires an ActiveStorage::VariantWithRecord or Preview, got #{source.class}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def async_variant_turbo_frame(variant, kind:, direct:, html_options:, &block)
|
|
52
|
+
content_tag(
|
|
53
|
+
:"turbo-frame",
|
|
54
|
+
id: async_variant_frame_id(variant),
|
|
55
|
+
src: async_variant_frame_src(variant, kind: kind, direct: direct, html_options: html_options),
|
|
56
|
+
refresh: "morph",
|
|
57
|
+
&block
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|