active_storage-async_variants 0.8.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 +23 -0
- data/CLAUDE.md +13 -7
- data/README.md +30 -57
- data/Rakefile +1 -20
- data/app/controllers/active_storage/async_variants/callbacks_controller.rb +28 -19
- data/config/routes.rb +0 -17
- data/db/migrate/20260304000001_add_state_columns_to_variant_records.rb +9 -0
- data/gemfiles/rails_7.2.gemfile +0 -4
- data/gemfiles/rails_8.0.gemfile +0 -4
- data/gemfiles/rails_8.1.gemfile +0 -4
- 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 +31 -34
- metadata +4 -47
- data/app/assets/javascripts/progress-bar.js +0 -347
- data/app/assets/javascripts/retry.js +0 -11
- data/app/assets/stylesheets/progress.css +0 -35
- data/app/assets/stylesheets/retry.css +0 -98
- data/app/controllers/active_storage/async_variants/states_controller.rb +0 -71
- data/app/views/active_storage/async_variants/states/_failed.html.erb +0 -44
- data/app/views/active_storage/async_variants/states/_pending.html.erb +0 -1
- data/app/views/active_storage/async_variants/states/_processed.html.erb +0 -5
- data/app/views/active_storage/async_variants/states/_processing.html.erb +0 -15
- data/app/views/active_storage/async_variants/states/show.html.erb +0 -10
- data/features/retry_flow.feature +0 -20
- data/features/retry_visibility.feature +0 -23
- data/features/state_rendering.feature +0 -40
- data/features/step_definitions/dummy_steps.rb +0 -144
- data/features/support/env.rb +0 -28
- data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +0 -59
- data/lib/active_storage/async_variants/helper.rb +0 -80
|
@@ -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
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "turbo-rails"
|
|
4
|
-
require "isolate_assets"
|
|
5
3
|
require_relative "async_variants/version"
|
|
6
|
-
require_relative "async_variants/helper"
|
|
7
4
|
require_relative "async_variants/transformer"
|
|
8
5
|
require_relative "async_variants/registry"
|
|
6
|
+
require_relative "async_variants/named_variant_scan"
|
|
9
7
|
require_relative "async_variants/blob_extension"
|
|
10
8
|
require_relative "async_variants/variation_extension"
|
|
11
9
|
require_relative "async_variants/variant_with_record_extension"
|
|
@@ -15,41 +13,18 @@ require_relative "async_variants/attachment_extension"
|
|
|
15
13
|
require_relative "async_variants/reflection_extension"
|
|
16
14
|
require_relative "async_variants/process_job"
|
|
17
15
|
require_relative "async_variants/heartbeat_watchdog_job"
|
|
18
|
-
require_relative "async_variants/asset_tag_helper_extension"
|
|
19
16
|
|
|
20
17
|
module ActiveStorage
|
|
21
18
|
module AsyncVariants
|
|
22
|
-
# HTML attributes round-tripped through the state-endpoint URL so the
|
|
23
|
-
# eventual processed-state render can apply them to the inner <img>/<video>.
|
|
24
|
-
PASS_THROUGH_HTML_OPTIONS = %i[alt width height controls autoplay preload].freeze
|
|
25
|
-
|
|
26
|
-
mattr_accessor :cdn_host
|
|
27
|
-
mattr_accessor :retry_visible_proc, default: ->(_view) { false }
|
|
28
|
-
|
|
29
|
-
# Lets the host app plug its auth chain (and thus `current_user`) into the
|
|
30
|
-
# gem's StatesController. Set to a string so resolution is deferred until
|
|
31
|
-
# the host's class is autoloadable. Defaults to ActionController::Base.
|
|
32
|
-
mattr_accessor :parent_controller, default: "ActionController::Base"
|
|
33
|
-
|
|
34
19
|
# How long an external transform may go without a heartbeat before the
|
|
35
20
|
# watchdog fails it. Must exceed the transformer's heartbeat interval.
|
|
36
21
|
mattr_accessor :heartbeat_stale_after, default: 60.seconds
|
|
37
22
|
|
|
38
|
-
# The transformer's heartbeat cadence.
|
|
39
|
-
#
|
|
23
|
+
# The transformer's heartbeat cadence. External services report at this
|
|
24
|
+
# rate; the UI layer's turbo-frame poll matches it. Keep heartbeat_stale_after
|
|
25
|
+
# well above it.
|
|
40
26
|
mattr_accessor :heartbeat_interval, default: 5.seconds
|
|
41
27
|
|
|
42
|
-
# Gates the failed-state retry affordance; the block runs in the view context.
|
|
43
|
-
def self.retry_visible_if(&block)
|
|
44
|
-
self.retry_visible_proc = block
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def self.retry_visible?(view)
|
|
48
|
-
!!view.instance_exec(&retry_visible_proc)
|
|
49
|
-
rescue StandardError
|
|
50
|
-
false
|
|
51
|
-
end
|
|
52
|
-
|
|
53
28
|
def self.configure
|
|
54
29
|
yield self
|
|
55
30
|
end
|
|
@@ -73,10 +48,6 @@ module ActiveStorage
|
|
|
73
48
|
# demand, and we just need the extensions in place by the time
|
|
74
49
|
# the first one loads.
|
|
75
50
|
ActiveStorage::AsyncVariants.prepend_model_extensions!
|
|
76
|
-
|
|
77
|
-
ActionView::Helpers::AssetTagHelper.prepend(
|
|
78
|
-
ActiveStorage::AsyncVariants::AssetTagHelperExtension
|
|
79
|
-
)
|
|
80
51
|
end
|
|
81
52
|
end
|
|
82
53
|
|
|
@@ -105,7 +76,33 @@ module ActiveStorage
|
|
|
105
76
|
)
|
|
106
77
|
end
|
|
107
78
|
|
|
108
|
-
|
|
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
|
|
109
106
|
|
|
110
107
|
def self.callback_token_for(variant_record)
|
|
111
108
|
ActiveStorage.verifier.generate(variant_record.id, purpose: :async_variant_callback)
|
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
|
|
@@ -23,34 +23,6 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '7.2'
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: turbo-rails
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - ">="
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '2.0'
|
|
33
|
-
type: :runtime
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - ">="
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '2.0'
|
|
40
|
-
- !ruby/object:Gem::Dependency
|
|
41
|
-
name: isolate_assets
|
|
42
|
-
requirement: !ruby/object:Gem::Requirement
|
|
43
|
-
requirements:
|
|
44
|
-
- - ">="
|
|
45
|
-
- !ruby/object:Gem::Version
|
|
46
|
-
version: '0.4'
|
|
47
|
-
type: :runtime
|
|
48
|
-
prerelease: false
|
|
49
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
-
requirements:
|
|
51
|
-
- - ">="
|
|
52
|
-
- !ruby/object:Gem::Version
|
|
53
|
-
version: '0.4'
|
|
54
26
|
email:
|
|
55
27
|
- micah@botandrose.com
|
|
56
28
|
executables: []
|
|
@@ -66,34 +38,19 @@ files:
|
|
|
66
38
|
- LICENSE.txt
|
|
67
39
|
- README.md
|
|
68
40
|
- Rakefile
|
|
69
|
-
- app/assets/javascripts/progress-bar.js
|
|
70
|
-
- app/assets/javascripts/retry.js
|
|
71
|
-
- app/assets/stylesheets/progress.css
|
|
72
|
-
- app/assets/stylesheets/retry.css
|
|
73
41
|
- app/controllers/active_storage/async_variants/callbacks_controller.rb
|
|
74
|
-
- app/controllers/active_storage/async_variants/states_controller.rb
|
|
75
|
-
- app/views/active_storage/async_variants/states/_failed.html.erb
|
|
76
|
-
- app/views/active_storage/async_variants/states/_pending.html.erb
|
|
77
|
-
- app/views/active_storage/async_variants/states/_processed.html.erb
|
|
78
|
-
- app/views/active_storage/async_variants/states/_processing.html.erb
|
|
79
|
-
- app/views/active_storage/async_variants/states/show.html.erb
|
|
80
42
|
- config/routes.rb
|
|
43
|
+
- db/migrate/20260304000001_add_state_columns_to_variant_records.rb
|
|
81
44
|
- db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb
|
|
82
45
|
- db/migrate/20260608000001_add_created_at_to_variant_records.rb
|
|
83
|
-
- features/retry_flow.feature
|
|
84
|
-
- features/retry_visibility.feature
|
|
85
|
-
- features/state_rendering.feature
|
|
86
|
-
- features/step_definitions/dummy_steps.rb
|
|
87
|
-
- features/support/env.rb
|
|
88
46
|
- gemfiles/rails_7.2.gemfile
|
|
89
47
|
- gemfiles/rails_8.0.gemfile
|
|
90
48
|
- gemfiles/rails_8.1.gemfile
|
|
91
49
|
- lib/active_storage/async_variants.rb
|
|
92
|
-
- lib/active_storage/async_variants/asset_tag_helper_extension.rb
|
|
93
50
|
- lib/active_storage/async_variants/attachment_extension.rb
|
|
94
51
|
- lib/active_storage/async_variants/blob_extension.rb
|
|
95
52
|
- lib/active_storage/async_variants/heartbeat_watchdog_job.rb
|
|
96
|
-
- lib/active_storage/async_variants/
|
|
53
|
+
- lib/active_storage/async_variants/named_variant_scan.rb
|
|
97
54
|
- lib/active_storage/async_variants/preview_extension.rb
|
|
98
55
|
- lib/active_storage/async_variants/process_job.rb
|
|
99
56
|
- lib/active_storage/async_variants/reflection_extension.rb
|