active_storage-async_variants 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +13 -7
- data/README.md +27 -11
- data/app/controllers/active_storage/async_variants/callbacks_controller.rb +28 -19
- data/db/migrate/20260304000001_add_state_columns_to_variant_records.rb +9 -0
- data/lib/active_storage/async_variants/attachment_extension.rb +19 -2
- data/lib/active_storage/async_variants/blob_extension.rb +3 -1
- data/lib/active_storage/async_variants/named_variant_scan.rb +55 -0
- data/lib/active_storage/async_variants/preview_extension.rb +52 -37
- data/lib/active_storage/async_variants/process_job.rb +53 -17
- data/lib/active_storage/async_variants/reflection_extension.rb +2 -2
- data/lib/active_storage/async_variants/registry.rb +1 -1
- data/lib/active_storage/async_variants/transformer.rb +32 -1
- data/lib/active_storage/async_variants/variant_record_extension.rb +15 -0
- data/lib/active_storage/async_variants/variant_with_record_extension.rb +11 -30
- data/lib/active_storage/async_variants/variation_extension.rb +8 -5
- data/lib/active_storage/async_variants/version.rb +1 -1
- data/lib/active_storage/async_variants.rb +29 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fffff13cd7b602bc0139ae60dff32c4d38d573b738edaa244f21df5dfa331723
|
|
4
|
+
data.tar.gz: 1251c74b70fd15eaaf1e48e494b7fc9d773f2989f83e1b99ca6862fdaec6c144
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2bfef2ba7b3d4576d20a867095c7e75f8f36ab122d0718901856ce0bab8330521f5edeb73a0e75a12f3ed20250fe28c25304b4823929167daa910d240a7b9477
|
|
7
|
+
data.tar.gz: f0e5e7a9cfe3d75ff7afe960fc7675447b466dc6e8500b7db6fca3c6a24cd8d0dfa837e938b216898a8ee8d95f470c1aa80b03be7c407e8e0ee7a63a46d74775
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## [0.10.0]
|
|
2
|
+
|
|
3
|
+
- **Breaking:** Async previews now persist the same graph stock Active Storage builds. For a previewable blob (a video), an image-format variant is a `Preview`: the extracted frame is attached as the source blob's `preview_image`, and the variant record hangs off the *frame* blob, not the source. `ProcessJob` resolves the attachment's `representation` and takes the preview path when stock would. External transformers receive a frame placeholder (`byte_size: 0, checksum: "0"`) to write into; the success callback reconciles both placeholders. Read video stills through `representation`/`preview`, not `variant`.
|
|
4
|
+
- **Breaking:** Naming a `transformer:` is now the only async opt-in. The `async:` option is gone and raises `ArgumentError`. The built-in `ActiveStorage::AsyncVariants::Standard` transformer replaces a bare `async: true` (the stock variant pipeline, run in the background). A transformer's kind follows from what it overrides: `Transformer#standard?` (neither), `#inline?` (`process`), `#external?` (`initiate`).
|
|
5
|
+
- **Breaking:** Attaching a file enqueues an async variant only when it declares Active Storage's `preprocessed:` option (`true`, a method name, or a proc evaluated against the record). Previously every variant naming a `transformer:` was enqueued on every attach, whatever the file — so an image upload also ran the attachment's video transcode. Stock Active Storage no longer sees async variants when preprocessing, so `preprocessed: true` never also queues a `TransformJob` or a local `PreviewImageJob`.
|
|
6
|
+
- Migration note: existing variant records for video stills live on the source blob under the old structure. Move them onto the frame blob (`UPDATE active_storage_variant_records SET blob_id = <frame>`), attaching a placeholder frame where none exists; the `variation_digest` is unchanged. Records whose output is itself a video (transcodes) stay on the source. Replace `async: true` with `transformer: ActiveStorage::AsyncVariants::Standard` where no transformer was named, and drop it where one was. Add `preprocessed: true` (or a condition) to each async variant that should process on attach.
|
|
7
|
+
- `Preview` speaks the same progress protocol as `VariantWithRecord`: `async_state`, `progress`, `progress_known?`, `progress_rate`, `last_heartbeat_at`, `error`.
|
|
8
|
+
- `async_record` is public on both `VariantWithRecord` and `Preview`, and both gain `retry!` (drop a failed record, then `enqueue!`), so callers need not know where a representation keeps its record.
|
|
9
|
+
- `NamedVariantScan` compares declarations structurally instead of instantiating `attachment.variant`, which raises for videos.
|
|
10
|
+
- Fix: declaring `preprocessed:` alongside a `transformer:` no longer breaks the declaration-time registry warm-up (a proc raised on `Marshal.dump`; other values registered the wrong digest).
|
|
11
|
+
- MirrorService: the success callback mirrors reconciled variant blobs to the mirrors, as Rails does for direct uploads, and `bucket_backed?` resolves through the primary service.
|
|
12
|
+
- Frame placeholder creation is serialized on the blob row: every image variant of one video reaches it concurrently, and a second `preview_image` attachment would be invisible to `has_one`, stranding the records hanging off it.
|
|
13
|
+
- Ships the `state`/`error`/`attempts` migration for `active_storage_variant_records` (guarded with `if_not_exists`, so apps that added the columns themselves can install it harmlessly), and builds the test schema from `db/migrate` so a missing shipped migration fails the suite.
|
|
14
|
+
- Fix: `ProcessJob` skips a variant record that is already processed or whose run is still heartbeating. Previously every enqueue re-dispatched the variant — attaching an already-processed blob to another record, a retry, or a duplicate job paid for a second render and flipped processed media back to processing.
|
|
15
|
+
- Fix: the success callback applies the reported `byte_size` and `checksum` whenever they arrive, not only over the `0`/`"0"` placeholders, since a re-render writes over the record's existing output blob. Reported `preview_image_*` values land only on a frame, never on a variant's source blob.
|
|
16
|
+
- Fix: destroying a variant record purges any output a racing render attached after the destroy read its attachments, so a purged source no longer leaves outputs pointing at a deleted record.
|
|
17
|
+
|
|
1
18
|
## [0.9.0]
|
|
2
19
|
|
|
3
20
|
- **Breaking:** Split the opinionated UI out into a separate gem, [`active_storage-async_variants-ui`](https://github.com/botandrose/active_storage-async_variants-ui). This gem is now UI-free plumbing — the async state machine, `ProcessJob`, the heartbeat watchdog, the external-service callback endpoint, and the `Transformer` base class. Its only runtime dependency is `activestorage` (no more `turbo-rails` / `isolate_assets`).
|
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.
|
|
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. Naming a `transformer:` on a variant definition opts it into async processing.
|
|
8
8
|
|
|
9
9
|
## Commands
|
|
10
10
|
|
|
@@ -19,17 +19,23 @@ 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
|
|
23
|
-
- **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for variants
|
|
22
|
+
- **`VariationExtension`** → `ActiveStorage::Variation` — extracts the `transformer:` option from variant config before passing the rest to standard Active Storage
|
|
23
|
+
- **`AttachmentExtension`** → `ActiveStorage::Attachment` — hooks into `transform_variants_later` to enqueue `ProcessJob` for async variants whose `preprocessed:` holds for the record, hiding async variants from stock preprocessing
|
|
24
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
|
+
- **`PreviewExtension`** → `ActiveStorage::Preview` — blocks the synchronous preview transform for async variants and resolves state/URLs through the stock preview graph
|
|
26
|
+
- **`ProcessJob`** — background job that resolves the attachment's representation (variant vs preview), determines transformer type (inline vs external), and processes accordingly
|
|
27
|
+
|
|
28
|
+
### Preview Structure
|
|
29
|
+
|
|
30
|
+
Async previews persist the same graph stock Active Storage builds: the extracted frame is attached as the source blob's `preview_image`, and the variant record hangs off the *frame* blob, not the source. External transformers get a frame placeholder (`byte_size: 0, checksum: "0"`) the service writes into; the success callback reconciles both the variant and frame placeholders. Inline/default transformers extract the real frame up front via the stock previewer.
|
|
26
31
|
|
|
27
32
|
### Transformer Types
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
- **External**: implements `initiate(source_url:, destination_url:, callback_url:, **options)` → frees worker immediately, external service POSTs to callback URL when done
|
|
34
|
+
Naming a `transformer:` is the async opt-in; the kind is determined by what the transformer overrides (`Transformer#standard?`/`#inline?`/`#external?`):
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
- **Standard** (`ActiveStorage::AsyncVariants::Standard`): overrides neither → runs the stock Active Storage variant pipeline in the background. Replaces the old bare `async: true` (no transformer) declaration, which now raises.
|
|
37
|
+
- **Inline**: overrides `process(file, **options)` → blocks worker, returns `{ io:, content_type:, filename: }`
|
|
38
|
+
- **External**: overrides `initiate(source_url:, callback_url:, **options)` → frees worker immediately, external service POSTs to callback URL when done
|
|
33
39
|
|
|
34
40
|
### Callback Endpoint
|
|
35
41
|
|
data/README.md
CHANGED
|
@@ -21,7 +21,7 @@ bin/rails db:migrate
|
|
|
21
21
|
|
|
22
22
|
## Usage
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Name a `transformer:` on any variant to opt it into the async pipeline:
|
|
25
25
|
|
|
26
26
|
```ruby
|
|
27
27
|
class User < ApplicationRecord
|
|
@@ -29,33 +29,49 @@ class User < ApplicationRecord
|
|
|
29
29
|
attachable.variant :web,
|
|
30
30
|
transformer: VideoTranscoder,
|
|
31
31
|
codec: "vp9",
|
|
32
|
-
resolution: "720p"
|
|
33
|
-
async: true
|
|
32
|
+
resolution: "720p"
|
|
34
33
|
end
|
|
35
34
|
end
|
|
36
35
|
```
|
|
37
36
|
|
|
38
|
-
The presence of
|
|
37
|
+
The presence of a `transformer:` is what opts a variant into async processing. Without it, variants behave exactly as they do in standard Active Storage. The transformer also says *how* the work happens — there are three kinds, distinguished by what they implement:
|
|
39
38
|
|
|
40
39
|
```ruby
|
|
41
40
|
has_one_attached :video do |attachable|
|
|
42
|
-
#
|
|
41
|
+
# External: hands off to a remote service (implements #initiate)
|
|
43
42
|
attachable.variant :web,
|
|
44
43
|
transformer: VideoTranscoder,
|
|
45
|
-
codec: "vp9"
|
|
46
|
-
async: true
|
|
44
|
+
codec: "vp9"
|
|
47
45
|
|
|
48
|
-
#
|
|
46
|
+
# Standard: the stock Active Storage variant pipeline, run in the background
|
|
47
|
+
# (e.g. a large image resize that's too slow for an inline request)
|
|
49
48
|
attachable.variant :thumbnail,
|
|
50
49
|
resize_to_limit: [200, 200],
|
|
51
|
-
|
|
50
|
+
transformer: ActiveStorage::AsyncVariants::Standard
|
|
52
51
|
|
|
53
|
-
#
|
|
52
|
+
# Inline: custom processing in the worker (implements #process)
|
|
54
53
|
attachable.variant :watermarked,
|
|
55
54
|
transformer: WatermarkStamper
|
|
56
55
|
end
|
|
57
56
|
```
|
|
58
57
|
|
|
58
|
+
A variant with no `transformer:` is a plain synchronous Active Storage variant — the gem stays out of the way.
|
|
59
|
+
|
|
60
|
+
### Processing on attach
|
|
61
|
+
|
|
62
|
+
An async variant is processed when a file is attached only if it says so with Active Storage's own `preprocessed:` option. It takes `true`, a method name, or a proc, evaluated against the record — so one attachment that accepts both images and videos can transcode only the videos:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
has_one_attached :media do |attachable|
|
|
66
|
+
attachable.variant :thumb, resize_to_limit: [150, 150], format: "webp",
|
|
67
|
+
transformer: Crucible, preprocessed: true
|
|
68
|
+
attachable.variant :transcoded, resize_to_limit: [1280, 720], format: "mp4",
|
|
69
|
+
transformer: Crucible, preprocessed: :video?
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
An async variant without `preprocessed:` is never enqueued on attach; call `enqueue!` on it when you want it. Stock Active Storage never preprocesses an async variant itself — the gem's `ProcessJob` is the only thing that does.
|
|
74
|
+
|
|
59
75
|
In views, use the same Active Storage helpers:
|
|
60
76
|
|
|
61
77
|
```erb
|
|
@@ -205,7 +221,7 @@ variant.error # => "ffmpeg exited with status 1: ..."
|
|
|
205
221
|
### External transformer flow
|
|
206
222
|
|
|
207
223
|
1. User uploads a file to an attachment that has async variants defined
|
|
208
|
-
2. After attachment, a background job is enqueued for each async variant
|
|
224
|
+
2. After attachment, a background job is enqueued for each async variant whose `preprocessed:` holds for the record
|
|
209
225
|
3. `VariantRecord` is created with state `pending`
|
|
210
226
|
4. The job calls the transformer's `initiate` method with presigned source/destination URLs and a signed callback URL, then exits -- the worker is free
|
|
211
227
|
5. The external service processes the file, uploads the result to the destination URL
|
|
@@ -4,9 +4,6 @@ module ActiveStorage
|
|
|
4
4
|
module AsyncVariants
|
|
5
5
|
class CallbacksController < ActionController::API
|
|
6
6
|
def create
|
|
7
|
-
variant_record_id = ActiveStorage.verifier.verify(params[:token], purpose: :async_variant_callback)
|
|
8
|
-
variant_record = ActiveStorage::VariantRecord.find(variant_record_id)
|
|
9
|
-
|
|
10
7
|
case params[:status]
|
|
11
8
|
when "success"
|
|
12
9
|
variant_record.update!(state: "processed")
|
|
@@ -29,33 +26,45 @@ module ActiveStorage
|
|
|
29
26
|
|
|
30
27
|
private
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
def variant_record
|
|
30
|
+
@variant_record ||= begin
|
|
31
|
+
variant_record_id = ActiveStorage.verifier.verify(params[:token], purpose: :async_variant_callback)
|
|
32
|
+
ActiveStorage::VariantRecord.find(variant_record_id)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
36
|
def apply_reported_metadata(variant_record, params)
|
|
37
37
|
reconcile(variant_record.image.blob, params[:byte_size], params[:checksum])
|
|
38
|
-
|
|
38
|
+
if frame?(variant_record.blob)
|
|
39
|
+
reconcile(variant_record.blob, params[:preview_image_byte_size], params[:preview_image_checksum])
|
|
40
|
+
end
|
|
39
41
|
end
|
|
40
42
|
|
|
41
|
-
#
|
|
42
|
-
# this is idempotent and never overwrites a real source blob's metadata.
|
|
43
|
+
# A re-render writes over a reused blob, so the reported values describe the object now stored.
|
|
43
44
|
def reconcile(blob, byte_size, checksum)
|
|
44
45
|
return unless blob
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
attrs[:byte_size] = bytes
|
|
47
|
+
if positive_int?(byte_size)
|
|
48
|
+
blob.byte_size = byte_size
|
|
49
49
|
end
|
|
50
|
-
if checksum.present?
|
|
51
|
-
|
|
50
|
+
if checksum.present?
|
|
51
|
+
blob.checksum = checksum
|
|
52
52
|
end
|
|
53
|
-
blob.
|
|
53
|
+
blob.save!
|
|
54
|
+
|
|
55
|
+
if blob.checksum != "0"
|
|
56
|
+
blob.mirror_later
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# preview_image_* must never land on a variant's source blob.
|
|
61
|
+
def frame?(blob)
|
|
62
|
+
blob.checksum == "0" ||
|
|
63
|
+
ActiveStorage::Attachment.exists?(record_type: "ActiveStorage::Blob", name: "preview_image", blob_id: blob.id)
|
|
54
64
|
end
|
|
55
65
|
|
|
56
|
-
def positive_int(value)
|
|
57
|
-
|
|
58
|
-
int.positive? ? int : nil
|
|
66
|
+
def positive_int?(value)
|
|
67
|
+
value.to_i.positive?
|
|
59
68
|
end
|
|
60
69
|
end
|
|
61
70
|
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddStateColumnsToVariantRecords < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
add_column :active_storage_variant_records, :state, :string, default: "pending", if_not_exists: true
|
|
6
|
+
add_column :active_storage_variant_records, :error, :text, if_not_exists: true
|
|
7
|
+
add_column :active_storage_variant_records, :attempts, :integer, default: 0, if_not_exists: true
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -5,22 +5,39 @@ module ActiveStorage
|
|
|
5
5
|
module AttachmentExtension
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
+
# Stock would also preprocess an async variant: a TransformJob, or a PreviewImageJob that runs the local previewer.
|
|
8
9
|
def transform_variants_later
|
|
9
|
-
super
|
|
10
|
+
without_async_variants { super }
|
|
10
11
|
enqueue_async_variant_jobs
|
|
11
12
|
end
|
|
12
13
|
|
|
14
|
+
def named_variants
|
|
15
|
+
return super unless @without_async_variants
|
|
16
|
+
super.reject { |_name, named_variant| async_variant?(named_variant) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def without_async_variants
|
|
20
|
+
@without_async_variants = true
|
|
21
|
+
yield
|
|
22
|
+
ensure
|
|
23
|
+
@without_async_variants = false
|
|
24
|
+
end
|
|
25
|
+
|
|
13
26
|
def enqueue_async_variant_jobs
|
|
14
27
|
return unless blob.bucket_backed?
|
|
15
28
|
|
|
16
29
|
named_variants.each do |name, named_variant|
|
|
17
|
-
next unless named_variant.
|
|
30
|
+
next unless async_variant?(named_variant) && named_variant.preprocessed?(record)
|
|
18
31
|
|
|
19
32
|
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
20
33
|
record, self.name, name.to_s,
|
|
21
34
|
)
|
|
22
35
|
end
|
|
23
36
|
end
|
|
37
|
+
|
|
38
|
+
def async_variant?(named_variant)
|
|
39
|
+
named_variant.transformations.key?(:transformer)
|
|
40
|
+
end
|
|
24
41
|
end
|
|
25
42
|
end
|
|
26
43
|
end
|
|
@@ -7,8 +7,10 @@ module ActiveStorage
|
|
|
7
7
|
# processing workers (Crucible, etc.) can reach via presigned URLs. The
|
|
8
8
|
# Disk and Test services return false; the gem then defers to vanilla
|
|
9
9
|
# ActiveStorage rather than trying to enqueue jobs or serve fallbacks.
|
|
10
|
+
# MirrorService doesn't delegate :bucket, so resolve through its primary.
|
|
10
11
|
def bucket_backed?
|
|
11
|
-
service.respond_to?(:
|
|
12
|
+
resolved = service.respond_to?(:primary) ? service.primary : service
|
|
13
|
+
resolved.respond_to?(:bucket)
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
# Finds the async named-variant declaration whose transformations match a
|
|
6
|
+
# variation, starting from a blob's attachments. Used to recover the
|
|
7
|
+
# :transformer option when the Registry is cold, and by
|
|
8
|
+
# Preview/VariantWithRecord enqueue paths to locate the attachment to
|
|
9
|
+
# dispatch ProcessJob against.
|
|
10
|
+
#
|
|
11
|
+
# Walks one level up through a preview_image attachment so a lookup from a
|
|
12
|
+
# video's extracted frame still finds the named variant declared on the
|
|
13
|
+
# parent record's source-video field.
|
|
14
|
+
#
|
|
15
|
+
# Candidates are compared structurally (order-insensitive, JSON-normalized)
|
|
16
|
+
# against the declared transformations, never by instantiating
|
|
17
|
+
# attachment.variant -- which raises InvariableError for previewable blobs.
|
|
18
|
+
module NamedVariantScan
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def find(blob, variation, depth: 0)
|
|
22
|
+
blob.attachments.each do |attachment|
|
|
23
|
+
if attachment.name == "preview_image" && attachment.record_type == "ActiveStorage::Blob" && depth < 1
|
|
24
|
+
source = ActiveStorage::Blob.find_by(id: attachment.record_id)
|
|
25
|
+
result = source && find(source, variation, depth: depth + 1)
|
|
26
|
+
return result if result
|
|
27
|
+
next
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attachment.send(:named_variants).each do |name, named_variant|
|
|
31
|
+
candidate = ActiveStorage::Variation.wrap(named_variant.transformations)
|
|
32
|
+
next unless candidate.async_options[:transformer]
|
|
33
|
+
return [attachment, name, candidate.async_options] if matches?(candidate, variation)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A format-less declaration matches any target format, because runtime
|
|
40
|
+
# resolution (Blob#variant's default_to) adds a blob-dependent format:
|
|
41
|
+
# the declaration never carries.
|
|
42
|
+
def matches?(candidate, variation)
|
|
43
|
+
declared = normalize(candidate.transformations)
|
|
44
|
+
target = normalize(variation.transformations)
|
|
45
|
+
declared_format = declared.delete("format")
|
|
46
|
+
target_format = target.delete("format")
|
|
47
|
+
declared == target && (declared_format.nil? || declared_format == target_format)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalize(transformations)
|
|
51
|
+
JSON.parse(transformations.to_json)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -11,13 +11,19 @@ module ActiveStorage
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def enqueue!
|
|
14
|
-
if
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
return if async_record
|
|
15
|
+
|
|
16
|
+
if result = ActiveStorage::AsyncVariants::NamedVariantScan.find(blob, variation)
|
|
17
|
+
attachment, variant_name, options = result
|
|
18
|
+
|
|
19
|
+
transformer = options[:transformer]
|
|
20
|
+
if transformer&.new&.external?
|
|
21
|
+
ActiveStorage::AsyncVariants.ensure_preview_image_placeholder!(blob)
|
|
22
|
+
blob.preview_image.blob.variant_records.create!(
|
|
23
|
+
variation_digest: variant.variation.digest,
|
|
24
|
+
state: "pending",
|
|
25
|
+
)
|
|
26
|
+
end
|
|
21
27
|
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
22
28
|
attachment.record, attachment.name, variant_name.to_s,
|
|
23
29
|
)
|
|
@@ -26,13 +32,27 @@ module ActiveStorage
|
|
|
26
32
|
# another caller (or a leftover record) wins; their job handles it
|
|
27
33
|
end
|
|
28
34
|
|
|
35
|
+
def retry!
|
|
36
|
+
async_record&.destroy if async_state == "failed"
|
|
37
|
+
enqueue!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Stock preview structure: the record hangs off the extracted frame (the
|
|
41
|
+
# blob's preview_image attachment), not the source blob. The digest is
|
|
42
|
+
# resolved through stock's Preview#variant so it matches the
|
|
43
|
+
# default_to-applied variation embedded in representation URLs.
|
|
44
|
+
def async_record
|
|
45
|
+
return nil unless blob.preview_image.attached?
|
|
46
|
+
blob.preview_image.blob.variant_records.find_by(variation_digest: variant.variation.digest)
|
|
47
|
+
end
|
|
48
|
+
|
|
29
49
|
def processed?
|
|
30
50
|
async_preview? ? preview_variant_processed? : super
|
|
31
51
|
end
|
|
32
52
|
|
|
33
53
|
def url(...)
|
|
34
54
|
if async_preview?
|
|
35
|
-
preview_variant_processed? ?
|
|
55
|
+
preview_variant_processed? ? async_record.image.url(...) : fallback_preview_url(...)
|
|
36
56
|
else
|
|
37
57
|
super
|
|
38
58
|
end
|
|
@@ -41,7 +61,7 @@ module ActiveStorage
|
|
|
41
61
|
def key
|
|
42
62
|
if async_preview?
|
|
43
63
|
raise ActiveStorage::Preview::UnprocessedError unless preview_variant_processed?
|
|
44
|
-
|
|
64
|
+
async_record.image.blob.key
|
|
45
65
|
else
|
|
46
66
|
super
|
|
47
67
|
end
|
|
@@ -49,30 +69,44 @@ module ActiveStorage
|
|
|
49
69
|
|
|
50
70
|
def async_state
|
|
51
71
|
return nil unless async_preview?
|
|
52
|
-
|
|
72
|
+
async_record&.state || "pending"
|
|
53
73
|
end
|
|
54
74
|
|
|
55
75
|
def progress
|
|
56
|
-
|
|
76
|
+
async_record&.progress
|
|
57
77
|
end
|
|
58
78
|
|
|
59
79
|
def progress_known?
|
|
60
|
-
!
|
|
80
|
+
!async_record&.progress.nil?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Mirror of VariantWithRecordExtension so a Preview is a drop-in for the
|
|
84
|
+
# UI layer's progress protocol: percent-per-second over elapsed processing
|
|
85
|
+
# time, driving the progress bar's optimistic creep between polls.
|
|
86
|
+
def progress_rate
|
|
87
|
+
record = async_record
|
|
88
|
+
return nil unless record&.created_at && record.last_heartbeat_at && record.progress&.positive?
|
|
89
|
+
elapsed = record.last_heartbeat_at - record.created_at
|
|
90
|
+
elapsed.positive? ? (record.progress / elapsed).round(4) : nil
|
|
61
91
|
end
|
|
62
92
|
|
|
63
93
|
def last_heartbeat_at
|
|
64
|
-
|
|
94
|
+
async_record&.last_heartbeat_at
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def error
|
|
98
|
+
async_record&.error
|
|
65
99
|
end
|
|
66
100
|
|
|
67
101
|
private
|
|
68
102
|
|
|
69
103
|
def async_preview?
|
|
70
|
-
resolved_async_options[:
|
|
104
|
+
resolved_async_options[:transformer].present?
|
|
71
105
|
end
|
|
72
106
|
|
|
73
107
|
# Variations rebuilt from the redirect URL only carry transformations --
|
|
74
|
-
# :
|
|
75
|
-
# embedded in the URL key. Recover
|
|
108
|
+
# :transformer is stripped at Variation#initialize and not
|
|
109
|
+
# embedded in the URL key. Recover it via the digest-keyed registry that
|
|
76
110
|
# VariationExtension warms on every view-side variant call, or fall back to
|
|
77
111
|
# scanning attached named variants when the registry is cold (autoloader
|
|
78
112
|
# hasn't touched the consumer model yet).
|
|
@@ -80,31 +114,12 @@ module ActiveStorage
|
|
|
80
114
|
@resolved_async_options ||=
|
|
81
115
|
variation.async_options.presence ||
|
|
82
116
|
ActiveStorage::AsyncVariants::Registry[variation.digest] ||
|
|
83
|
-
|
|
117
|
+
ActiveStorage::AsyncVariants::NamedVariantScan.find(blob, variation)&.dig(2) ||
|
|
84
118
|
{}
|
|
85
119
|
end
|
|
86
120
|
|
|
87
|
-
def find_named_async_variant
|
|
88
|
-
target = variation.transformations.to_json
|
|
89
|
-
blob.attachments.each do |attachment|
|
|
90
|
-
attachment.send(:named_variants).each do |name, _|
|
|
91
|
-
candidate = attachment.variant(name.to_sym)
|
|
92
|
-
next unless candidate.variation.transformations.to_json == target
|
|
93
|
-
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
nil
|
|
97
|
-
end
|
|
98
|
-
|
|
99
121
|
def preview_variant_processed?
|
|
100
|
-
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
# ProcessJob stores variant_records on the source blob (i.e. @variant.blob,
|
|
104
|
-
# which for a named variant declared on a previewable attachment is the
|
|
105
|
-
# original blob -- not preview_image.blob). Read from the same place.
|
|
106
|
-
def find_preview_variant_record
|
|
107
|
-
blob.variant_records.find_by(variation_digest: variation.digest)
|
|
122
|
+
async_record&.state == "processed"
|
|
108
123
|
end
|
|
109
124
|
|
|
110
125
|
# Serve the original until the preview variant is processed.
|
|
@@ -9,24 +9,12 @@ module ActiveStorage
|
|
|
9
9
|
|
|
10
10
|
def perform(record, attachment_name, variant_name)
|
|
11
11
|
attachment = record.public_send(attachment_name)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
blob = @variant.blob
|
|
17
|
-
|
|
18
|
-
@variant_record = blob.variant_records.create_or_find_by!(variation_digest: variation.digest)
|
|
19
|
-
@variant_record.update!(state: "processing")
|
|
20
|
-
|
|
21
|
-
if transformer_class
|
|
22
|
-
transformer = transformer_class.new
|
|
23
|
-
if transformer.inline?
|
|
24
|
-
process_inline(blob, @variant_record, transformer, variation)
|
|
25
|
-
else
|
|
26
|
-
process_external(blob, @variant_record, transformer, variation)
|
|
27
|
-
end
|
|
12
|
+
representation = attachment.representation(variant_name.to_sym)
|
|
13
|
+
|
|
14
|
+
if representation.is_a?(ActiveStorage::Preview)
|
|
15
|
+
perform_preview(representation)
|
|
28
16
|
else
|
|
29
|
-
|
|
17
|
+
perform_variant(representation)
|
|
30
18
|
end
|
|
31
19
|
rescue => e
|
|
32
20
|
@variant_record&.update!(
|
|
@@ -39,6 +27,54 @@ module ActiveStorage
|
|
|
39
27
|
|
|
40
28
|
private
|
|
41
29
|
|
|
30
|
+
def perform_variant(variant)
|
|
31
|
+
blob = variant.blob
|
|
32
|
+
@variant_record = blob.variant_records.create_or_find_by!(variation_digest: variant.variation.digest)
|
|
33
|
+
return if @variant_record.processed? || @variant_record.in_flight?
|
|
34
|
+
@variant_record.update!(state: "processing", last_heartbeat_at: Time.current)
|
|
35
|
+
|
|
36
|
+
dispatch(variant.variation, transform_blob: blob, source_blob: blob)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Stock preview structure: the frame lives as a preview_image attachment
|
|
40
|
+
# on the source blob, and the variant record hangs off the frame blob.
|
|
41
|
+
# Standard/inline transformers need the real frame up front (the stock
|
|
42
|
+
# previewer extracts it); external transformers get a placeholder the
|
|
43
|
+
# service writes into, plus the source blob to extract from.
|
|
44
|
+
def perform_preview(preview)
|
|
45
|
+
blob = preview.blob
|
|
46
|
+
|
|
47
|
+
if transformer_for(preview.variation).external?
|
|
48
|
+
ActiveStorage::AsyncVariants.ensure_preview_image_placeholder!(blob)
|
|
49
|
+
else
|
|
50
|
+
preview.send(:process) unless blob.preview_image.attached?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
frame_blob = blob.preview_image.blob
|
|
54
|
+
variation = preview.send(:variant).variation
|
|
55
|
+
@variant_record = frame_blob.variant_records.create_or_find_by!(variation_digest: variation.digest)
|
|
56
|
+
return if @variant_record.processed? || @variant_record.in_flight?
|
|
57
|
+
@variant_record.update!(state: "processing", last_heartbeat_at: Time.current)
|
|
58
|
+
|
|
59
|
+
dispatch(variation, transform_blob: frame_blob, source_blob: blob)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def dispatch(variation, transform_blob:, source_blob:)
|
|
63
|
+
transformer = transformer_for(variation)
|
|
64
|
+
|
|
65
|
+
if transformer.standard?
|
|
66
|
+
process_default(transform_blob, @variant_record, variation)
|
|
67
|
+
elsif transformer.inline?
|
|
68
|
+
process_inline(transform_blob, @variant_record, transformer, variation)
|
|
69
|
+
else
|
|
70
|
+
process_external(source_blob, @variant_record, transformer, variation)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def transformer_for(variation)
|
|
75
|
+
(variation.async_options[:transformer] || ActiveStorage::AsyncVariants::Standard).new
|
|
76
|
+
end
|
|
77
|
+
|
|
42
78
|
def process_inline(blob, variant_record, transformer, variation)
|
|
43
79
|
options = variation.transformations
|
|
44
80
|
|
|
@@ -7,7 +7,7 @@ module ActiveStorage
|
|
|
7
7
|
#
|
|
8
8
|
# has_one_attached :avatar do |attachable|
|
|
9
9
|
# attachable.variant :thumb, resize_to_limit: [150, 150], format: "webp",
|
|
10
|
-
# transformer: Crucible
|
|
10
|
+
# transformer: Crucible
|
|
11
11
|
# end
|
|
12
12
|
#
|
|
13
13
|
# Without this, the Registry only warms when view-side code calls
|
|
@@ -28,7 +28,7 @@ module ActiveStorage
|
|
|
28
28
|
# matches the URL-side digest for variants that declare an explicit
|
|
29
29
|
# `format:`. Variants without an explicit `format:` still warm lazily
|
|
30
30
|
# on first view-side call against a blob of that content type.
|
|
31
|
-
ActiveStorage::Variation.wrap(transformations).default_to(format: :png)
|
|
31
|
+
ActiveStorage::Variation.wrap(transformations.except(:preprocessed)).default_to(format: :png)
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
end
|
|
@@ -7,7 +7,7 @@ module ActiveStorage
|
|
|
7
7
|
# Process-wide lookup of async_options keyed by Variation#digest.
|
|
8
8
|
#
|
|
9
9
|
# Populated lazily by VariationExtension#initialize whenever a Variation is
|
|
10
|
-
# constructed with :
|
|
10
|
+
# constructed with a :transformer 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.
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
|
+
# Naming a transformer on a variant is what opts it into async processing.
|
|
6
|
+
# Three kinds, distinguished by what they override:
|
|
7
|
+
#
|
|
8
|
+
# - standard (overrides neither) -- runs the stock Active Storage variant
|
|
9
|
+
# pipeline in the background. Use the built-in Standard below.
|
|
10
|
+
# - inline (overrides #process) -- does the work in the worker, returns
|
|
11
|
+
# { io:, content_type:, filename: }.
|
|
12
|
+
# - external (overrides #initiate) -- hands off to a remote service that
|
|
13
|
+
# POSTs back to the callback URL when done.
|
|
5
14
|
class Transformer
|
|
6
15
|
def process(file, **options)
|
|
7
16
|
raise NotImplementedError, "#{self.class}#process must return { io:, content_type:, filename: }"
|
|
@@ -12,8 +21,30 @@ module ActiveStorage
|
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
def inline?
|
|
15
|
-
|
|
24
|
+
overrides?(:process)
|
|
16
25
|
end
|
|
26
|
+
|
|
27
|
+
def external?
|
|
28
|
+
overrides?(:initiate)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def standard?
|
|
32
|
+
!inline? && !external?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def overrides?(method_name)
|
|
38
|
+
self.class.instance_method(method_name).owner != ActiveStorage::AsyncVariants::Transformer
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Async processing via the stock Active Storage variant pipeline -- the
|
|
43
|
+
# replacement for the old bare `async: true` (no transformer) declaration.
|
|
44
|
+
#
|
|
45
|
+
# attachable.variant :thumb, resize_to_limit: [100, 100],
|
|
46
|
+
# transformer: ActiveStorage::AsyncVariants::Standard
|
|
47
|
+
class Standard < Transformer
|
|
17
48
|
end
|
|
18
49
|
end
|
|
19
50
|
end
|
|
@@ -7,6 +7,16 @@ module ActiveStorage
|
|
|
7
7
|
|
|
8
8
|
included do
|
|
9
9
|
after_update_commit :touch_attached_records, if: :reached_terminal_state?
|
|
10
|
+
after_destroy_commit :purge_late_outputs
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def processed?
|
|
14
|
+
state == "processed"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A run that stopped heartbeating is presumed dead, so it may be retried.
|
|
18
|
+
def in_flight?
|
|
19
|
+
state == "processing" && last_heartbeat_at.present? && last_heartbeat_at > ActiveStorage::AsyncVariants.heartbeat_stale_after.ago
|
|
10
20
|
end
|
|
11
21
|
|
|
12
22
|
private
|
|
@@ -19,6 +29,11 @@ module ActiveStorage
|
|
|
19
29
|
saved_change_to_state? && %w[processed failed].include?(state)
|
|
20
30
|
end
|
|
21
31
|
|
|
32
|
+
# A render that held the row lock can commit an output after this destroy read the record's attachments.
|
|
33
|
+
def purge_late_outputs
|
|
34
|
+
ActiveStorage::Attachment.where(record: self).find_each(&:purge_later)
|
|
35
|
+
end
|
|
36
|
+
|
|
22
37
|
def touch_attached_records
|
|
23
38
|
blob.attachments.includes(:record).each do |attachment|
|
|
24
39
|
attachment.record&.touch
|
|
@@ -26,6 +26,15 @@ module ActiveStorage
|
|
|
26
26
|
# another caller (or a leftover record) wins; their job handles it
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def retry!
|
|
30
|
+
async_record&.destroy if failed?
|
|
31
|
+
enqueue!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def async_record
|
|
35
|
+
blob.variant_records.find_by(variation_digest: variation.digest)
|
|
36
|
+
end
|
|
37
|
+
|
|
29
38
|
def url(...)
|
|
30
39
|
if blob.bucket_backed? && async_variant? && !processed?
|
|
31
40
|
blob.url(...)
|
|
@@ -87,7 +96,7 @@ module ActiveStorage
|
|
|
87
96
|
private
|
|
88
97
|
|
|
89
98
|
def async_variant?
|
|
90
|
-
resolved_async_options[:
|
|
99
|
+
resolved_async_options[:transformer].present?
|
|
91
100
|
end
|
|
92
101
|
|
|
93
102
|
def resolved_async_options
|
|
@@ -103,36 +112,8 @@ module ActiveStorage
|
|
|
103
112
|
# a fallback when the Registry is cold (e.g. in dev, when a
|
|
104
113
|
# RepresentationsRedirectController request hits a worker that hasn't
|
|
105
114
|
# autoloaded the consumer model yet).
|
|
106
|
-
#
|
|
107
|
-
# Walks one level through preview_image attachments so a request for
|
|
108
|
-
# a Variant of a video's extracted preview frame can still find the
|
|
109
|
-
# named variant declared on the parent record's source-video field.
|
|
110
115
|
def find_named_async_variant
|
|
111
|
-
|
|
112
|
-
scan_for_named_variant(blob, target)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def scan_for_named_variant(blob_to_scan, target, depth: 0)
|
|
116
|
-
blob_to_scan.attachments.each do |attachment|
|
|
117
|
-
if attachment.name == "preview_image" && attachment.record_type == "ActiveStorage::Blob" && depth < 1
|
|
118
|
-
source = ActiveStorage::Blob.find_by(id: attachment.record_id)
|
|
119
|
-
result = source && scan_for_named_variant(source, target, depth: depth + 1)
|
|
120
|
-
return result if result
|
|
121
|
-
next
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
attachment.send(:named_variants).each do |name, _|
|
|
125
|
-
candidate = attachment.variant(name.to_sym)
|
|
126
|
-
if candidate.variation.transformations.to_json == target
|
|
127
|
-
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:async]
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
nil
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def async_record
|
|
135
|
-
blob.variant_records.find_by(variation_digest: variation.digest)
|
|
116
|
+
ActiveStorage::AsyncVariants::NamedVariantScan.find(blob, variation)
|
|
136
117
|
end
|
|
137
118
|
end
|
|
138
119
|
end
|
|
@@ -3,17 +3,20 @@
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
5
|
module VariationExtension
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
# Stripped before stock sees it (it would treat it as an unknown image
|
|
7
|
+
# operation). Naming a `:transformer` is the async opt-in.
|
|
8
8
|
def initialize(transformations)
|
|
9
9
|
if transformations.is_a?(Hash)
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
if transformations.key?(:async)
|
|
11
|
+
raise ArgumentError, "async: is no longer supported; name a transformer: to opt a variant into async processing"
|
|
12
|
+
end
|
|
13
|
+
@async_options = transformations.slice(:transformer)
|
|
14
|
+
super(transformations.except(:transformer))
|
|
12
15
|
else
|
|
13
16
|
@async_options = {}
|
|
14
17
|
super
|
|
15
18
|
end
|
|
16
|
-
ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:
|
|
19
|
+
ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:transformer]
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def async_options
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "async_variants/version"
|
|
4
4
|
require_relative "async_variants/transformer"
|
|
5
5
|
require_relative "async_variants/registry"
|
|
6
|
+
require_relative "async_variants/named_variant_scan"
|
|
6
7
|
require_relative "async_variants/blob_extension"
|
|
7
8
|
require_relative "async_variants/variation_extension"
|
|
8
9
|
require_relative "async_variants/variant_with_record_extension"
|
|
@@ -75,6 +76,34 @@ module ActiveStorage
|
|
|
75
76
|
)
|
|
76
77
|
end
|
|
77
78
|
|
|
79
|
+
# Attaches a frame placeholder (byte_size 0, checksum "0") as the blob's
|
|
80
|
+
# preview_image, mirroring stock's once-per-blob preview extraction. The
|
|
81
|
+
# external transformer writes the real frame to it; the success callback
|
|
82
|
+
# reconciles the sentinel metadata.
|
|
83
|
+
#
|
|
84
|
+
# Serialized on the blob row: every image variant of one video runs this
|
|
85
|
+
# concurrently, and a second frame would be invisible to has_one
|
|
86
|
+
# :preview_image, stranding the records that hang off it.
|
|
87
|
+
def self.ensure_preview_image_placeholder!(blob)
|
|
88
|
+
return blob.preview_image.blob if blob.preview_image.attached?
|
|
89
|
+
|
|
90
|
+
blob.with_lock do
|
|
91
|
+
# with_lock reloaded the blob, so this sees a frame a racing caller just committed.
|
|
92
|
+
next blob.preview_image.blob if blob.preview_image.attached?
|
|
93
|
+
|
|
94
|
+
frame = ActiveStorage::Blob.create_before_direct_upload!(
|
|
95
|
+
filename: "#{blob.filename.base}.jpg",
|
|
96
|
+
content_type: "image/jpeg",
|
|
97
|
+
metadata: { analyzed: true },
|
|
98
|
+
service_name: blob.service_name,
|
|
99
|
+
byte_size: 0,
|
|
100
|
+
checksum: "0",
|
|
101
|
+
)
|
|
102
|
+
blob.preview_image.attach(frame)
|
|
103
|
+
frame
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
78
107
|
def self.callback_token_for(variant_record)
|
|
79
108
|
ActiveStorage.verifier.generate(variant_record.id, purpose: :async_variant_callback)
|
|
80
109
|
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.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Micah Geisel
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-11-14 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activestorage
|
|
@@ -40,6 +40,7 @@ files:
|
|
|
40
40
|
- Rakefile
|
|
41
41
|
- app/controllers/active_storage/async_variants/callbacks_controller.rb
|
|
42
42
|
- config/routes.rb
|
|
43
|
+
- db/migrate/20260304000001_add_state_columns_to_variant_records.rb
|
|
43
44
|
- db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb
|
|
44
45
|
- db/migrate/20260608000001_add_created_at_to_variant_records.rb
|
|
45
46
|
- gemfiles/rails_7.2.gemfile
|
|
@@ -49,6 +50,7 @@ files:
|
|
|
49
50
|
- lib/active_storage/async_variants/attachment_extension.rb
|
|
50
51
|
- lib/active_storage/async_variants/blob_extension.rb
|
|
51
52
|
- lib/active_storage/async_variants/heartbeat_watchdog_job.rb
|
|
53
|
+
- lib/active_storage/async_variants/named_variant_scan.rb
|
|
52
54
|
- lib/active_storage/async_variants/preview_extension.rb
|
|
53
55
|
- lib/active_storage/async_variants/process_job.rb
|
|
54
56
|
- lib/active_storage/async_variants/reflection_extension.rb
|