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
|
@@ -11,8 +11,10 @@ module ActiveStorage
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def enqueue_async_variant_jobs
|
|
14
|
+
return unless blob.bucket_backed?
|
|
15
|
+
|
|
14
16
|
named_variants.each do |name, named_variant|
|
|
15
|
-
next unless named_variant.transformations.key?(:
|
|
17
|
+
next unless named_variant.transformations.key?(:processing)
|
|
16
18
|
|
|
17
19
|
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
18
20
|
record, self.name, name.to_s,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
module BlobExtension
|
|
6
|
+
# True when the blob's service is a remote/cloud service that the async
|
|
7
|
+
# processing workers (Crucible, etc.) can reach via presigned URLs. The
|
|
8
|
+
# Disk and Test services return false; the gem then defers to vanilla
|
|
9
|
+
# ActiveStorage rather than trying to enqueue jobs or serve fallbacks.
|
|
10
|
+
def bucket_backed?
|
|
11
|
+
service.respond_to?(:bucket)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
# View/controller helper methods. Included in the AssetTagHelperExtension
|
|
6
|
+
# (so image_tag/video_tag can use them) and added to StatesController via
|
|
7
|
+
# `helper ActiveStorage::AsyncVariants::Helper` (so the state partials can
|
|
8
|
+
# use them).
|
|
9
|
+
module Helper
|
|
10
|
+
# In test with non-bucket-backed services, the gem defers to vanilla
|
|
11
|
+
# ActiveStorage (synchronous vips transform) -- inline rendering keeps
|
|
12
|
+
# those environments simple. Otherwise, only inline a normal <img> when
|
|
13
|
+
# the variant has reached the processed terminal state.
|
|
14
|
+
def async_variant_processed_inline?(variant)
|
|
15
|
+
!variant.blob.bucket_backed? || variant.async_state == "processed"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def async_variant_resolved_src(variant, direct:)
|
|
19
|
+
if direct && variant.async_state == "processed"
|
|
20
|
+
async_variant_direct_url(variant)
|
|
21
|
+
else
|
|
22
|
+
variant.processed if variant.blob.bucket_backed?
|
|
23
|
+
async_variant_representation_path(variant)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def async_variant_frame_id(variant)
|
|
28
|
+
digest = variant.variation.digest.gsub(/[^a-zA-Z0-9_-]/, "")
|
|
29
|
+
"async-variant-#{variant.blob.id}-#{digest}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def async_variant_frame_src(variant, kind:, direct:, html_options: {})
|
|
33
|
+
async_variant_state_path(
|
|
34
|
+
signed_blob_id: variant.blob.signed_id,
|
|
35
|
+
variation_key: variant.variation.key,
|
|
36
|
+
kind:,
|
|
37
|
+
direct:,
|
|
38
|
+
opts: html_options.slice(*PASS_THROUGH_HTML_OPTIONS),
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def async_variant_direct_url(variant)
|
|
43
|
+
if cdn = ActiveStorage::AsyncVariants.cdn_host
|
|
44
|
+
"#{cdn}/#{variant.key}"
|
|
45
|
+
else
|
|
46
|
+
variant.image.url
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def async_variant_representation_path(variant)
|
|
51
|
+
Rails.application.routes.url_helpers.rails_blob_representation_path(
|
|
52
|
+
variant.blob.signed_id,
|
|
53
|
+
variant.variation.key,
|
|
54
|
+
variant.blob.filename.to_s,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
module PreviewExtension
|
|
6
|
+
# Block vanilla ActiveStorage's synchronous preview transform on
|
|
7
|
+
# async-backed services; rely on the auto-enqueue (AttachmentExtension)
|
|
8
|
+
# path -- and the public #enqueue! -- to dispatch ProcessJob.
|
|
9
|
+
def processed
|
|
10
|
+
async_preview? ? self : super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def enqueue!
|
|
14
|
+
if result = find_named_async_variant
|
|
15
|
+
attachment, variant_name, _ = result
|
|
16
|
+
|
|
17
|
+
blob.variant_records.create!(
|
|
18
|
+
variation_digest: variation.digest,
|
|
19
|
+
state: "pending",
|
|
20
|
+
)
|
|
21
|
+
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
22
|
+
attachment.record, attachment.name, variant_name.to_s,
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
rescue ActiveRecord::RecordNotUnique
|
|
26
|
+
# another caller (or a leftover record) wins; their job handles it
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def processed?
|
|
30
|
+
async_preview? ? preview_variant_processed? : super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def url(...)
|
|
34
|
+
if async_preview?
|
|
35
|
+
preview_variant_processed? ? find_preview_variant_record.image.url(...) : fallback_preview_url(...)
|
|
36
|
+
else
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def key
|
|
42
|
+
if async_preview?
|
|
43
|
+
raise ActiveStorage::Preview::UnprocessedError unless preview_variant_processed?
|
|
44
|
+
find_preview_variant_record.image.blob.key
|
|
45
|
+
else
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def async_state
|
|
51
|
+
return nil unless async_preview?
|
|
52
|
+
find_preview_variant_record&.state || "pending"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def async_preview?
|
|
58
|
+
resolved_async_options[:transformer].present?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Variations rebuilt from the redirect URL only carry transformations --
|
|
62
|
+
# :transformer / :processing / :failed are stripped at Variation#initialize
|
|
63
|
+
# and not embedded in the URL key. Recover them via the digest-keyed
|
|
64
|
+
# registry that VariationExtension warms on every view-side variant call,
|
|
65
|
+
# or fall back to scanning attached named variants when the registry is
|
|
66
|
+
# cold (autoloader hasn't touched the consumer model yet).
|
|
67
|
+
def resolved_async_options
|
|
68
|
+
@resolved_async_options ||=
|
|
69
|
+
variation.async_options.presence ||
|
|
70
|
+
ActiveStorage::AsyncVariants::Registry[variation.digest] ||
|
|
71
|
+
find_named_async_variant&.dig(2) ||
|
|
72
|
+
{}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def find_named_async_variant
|
|
76
|
+
target = variation.transformations.to_json
|
|
77
|
+
blob.attachments.each do |attachment|
|
|
78
|
+
attachment.send(:named_variants).each do |name, _|
|
|
79
|
+
candidate = attachment.variant(name.to_sym)
|
|
80
|
+
next unless candidate.variation.transformations.to_json == target
|
|
81
|
+
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:transformer].present?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def preview_variant_processed?
|
|
88
|
+
find_preview_variant_record&.state == "processed"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ProcessJob stores variant_records on the source blob (i.e. @variant.blob,
|
|
92
|
+
# which for a named variant declared on a previewable attachment is the
|
|
93
|
+
# original blob -- not preview_image.blob). Read from the same place.
|
|
94
|
+
def find_preview_variant_record
|
|
95
|
+
blob.variant_records.find_by(variation_digest: variation.digest)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def fallback_preview_url(...)
|
|
99
|
+
case active_fallback
|
|
100
|
+
when :original then blob.url(...)
|
|
101
|
+
when :blank then nil
|
|
102
|
+
when Proc then active_fallback.call(blob)
|
|
103
|
+
when String then active_fallback
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def active_fallback
|
|
108
|
+
if failed?
|
|
109
|
+
resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
|
|
110
|
+
else
|
|
111
|
+
resolved_async_options[:processing]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def failed?
|
|
116
|
+
find_preview_variant_record&.state == "failed"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
5
|
class ProcessJob < ActiveJob::Base
|
|
6
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
|
7
|
+
job.logger.error "AsyncVariants: permanently failed: #{error.message}"
|
|
8
|
+
end
|
|
9
|
+
|
|
6
10
|
def perform(record, attachment_name, variant_name)
|
|
7
11
|
attachment = record.public_send(attachment_name)
|
|
8
12
|
@variant = attachment.variant(variant_name.to_sym)
|
|
@@ -27,15 +31,10 @@ module ActiveStorage
|
|
|
27
31
|
rescue => e
|
|
28
32
|
@variant_record&.update!(
|
|
29
33
|
state: "failed",
|
|
30
|
-
error: e.message,
|
|
34
|
+
error: e.message.to_s.truncate(16_000),
|
|
31
35
|
attempts: (@variant_record&.attempts || 0) + 1,
|
|
32
36
|
)
|
|
33
|
-
|
|
34
|
-
if @variant_record.nil? || @variant_record.attempts < max_retries
|
|
35
|
-
raise
|
|
36
|
-
else
|
|
37
|
-
logger.error "AsyncVariants: permanently failed after #{@variant_record.attempts} attempts: #{e.message}"
|
|
38
|
-
end
|
|
37
|
+
raise
|
|
39
38
|
end
|
|
40
39
|
|
|
41
40
|
private
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
# Eagerly warm the digest-keyed Registry when a named variant is declared
|
|
6
|
+
# via has_X_attached's block:
|
|
7
|
+
#
|
|
8
|
+
# has_one_attached :avatar do |attachable|
|
|
9
|
+
# attachable.variant :thumb, resize_to_limit: [150, 150], format: "webp",
|
|
10
|
+
# transformer: Crucible, processing: "/spinner.svg"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Without this, the Registry only warms when view-side code calls
|
|
14
|
+
# `attachment.variant(:thumb)` -- so a Puma worker that has never rendered
|
|
15
|
+
# a view for the variant misses on URL-reconstructed lookups.
|
|
16
|
+
#
|
|
17
|
+
# Caveat: registration uses the declared transformations. Rails applies a
|
|
18
|
+
# blob-dependent default `format:` at runtime via default_to. Variants that
|
|
19
|
+
# specify `format:` explicitly register the same digest the URL carries
|
|
20
|
+
# (cold-resolvable). Variants that rely on the format default still warm
|
|
21
|
+
# lazily on first view-side call against a blob of that content type.
|
|
22
|
+
module ReflectionExtension
|
|
23
|
+
def variant(name, transformations)
|
|
24
|
+
super
|
|
25
|
+
return unless transformations.is_a?(Hash)
|
|
26
|
+
# Mirror Blob#variant at runtime: wrap and default_to. The default_to
|
|
27
|
+
# call reorders hash keys (default key first) so the registered digest
|
|
28
|
+
# matches the URL-side digest for variants that declare an explicit
|
|
29
|
+
# `format:`. Variants without an explicit `format:` still warm lazily
|
|
30
|
+
# on first view-side call against a blob of that content type.
|
|
31
|
+
ActiveStorage::Variation.wrap(transformations).default_to(format: :png)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module AsyncVariants
|
|
7
|
+
# Process-wide lookup of async_options keyed by Variation#digest.
|
|
8
|
+
#
|
|
9
|
+
# Populated lazily by VariationExtension#initialize whenever a Variation is
|
|
10
|
+
# constructed with :transformer in its async_options -- which happens on
|
|
11
|
+
# every view-side `attachment.variant(:name)` call. The redirect controller
|
|
12
|
+
# then resolves async_options by digest without scanning blob.attachments
|
|
13
|
+
# for a transformations-match.
|
|
14
|
+
#
|
|
15
|
+
# Cold-worker behavior: if a worker receives a redirect-URL request before
|
|
16
|
+
# any view-side call has registered the variant's digest, the lookup misses
|
|
17
|
+
# and resolution falls through to standard Rails (no leak, no spinner).
|
|
18
|
+
# Self-warms on subsequent view requests.
|
|
19
|
+
class Registry
|
|
20
|
+
MONITOR = Monitor.new
|
|
21
|
+
STORE = {}
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def register(digest, async_options)
|
|
25
|
+
MONITOR.synchronize { STORE[digest] = async_options }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def [](digest)
|
|
29
|
+
MONITOR.synchronize { STORE[digest] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def clear
|
|
33
|
+
MONITOR.synchronize { STORE.clear }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
module VariantRecordExtension
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
after_update_commit :touch_attached_records, if: :reached_terminal_state?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# processed and failed are terminal states: cache fragments built when
|
|
15
|
+
# state was pending/processing need to be invalidated so the next
|
|
16
|
+
# render sees the new state (and serves the failed: fallback URL,
|
|
17
|
+
# swaps src to the direct CDN URL, etc.).
|
|
18
|
+
def reached_terminal_state?
|
|
19
|
+
saved_change_to_state? && %w[processed failed].include?(state)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def touch_attached_records
|
|
23
|
+
blob.attachments.includes(:record).each do |attachment|
|
|
24
|
+
attachment.record&.touch
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -3,16 +3,50 @@
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
5
|
module VariantWithRecordExtension
|
|
6
|
+
# Block vanilla ActiveStorage's synchronous transform on bucket-backed
|
|
7
|
+
# services; rely on the auto-enqueue (AttachmentExtension) path -- and the
|
|
8
|
+
# public #enqueue! -- to dispatch ProcessJob.
|
|
9
|
+
def processed
|
|
10
|
+
blob.bucket_backed? ? self : super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def enqueue!
|
|
14
|
+
if result = find_named_async_variant
|
|
15
|
+
attachment, variant_name, _ = result
|
|
16
|
+
|
|
17
|
+
blob.variant_records.create!(
|
|
18
|
+
variation_digest: variation.digest,
|
|
19
|
+
state: "pending",
|
|
20
|
+
)
|
|
21
|
+
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
22
|
+
attachment.record, attachment.name, variant_name.to_s,
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
rescue ActiveRecord::RecordNotUnique
|
|
26
|
+
# another caller (or a leftover record) wins; their job handles it
|
|
27
|
+
end
|
|
28
|
+
|
|
6
29
|
def url(...)
|
|
7
|
-
if processed?
|
|
8
|
-
|
|
30
|
+
if blob.bucket_backed? && !processed?
|
|
31
|
+
fallback = active_fallback
|
|
32
|
+
case fallback
|
|
33
|
+
when :original then blob.url(...)
|
|
34
|
+
when :blank then nil
|
|
35
|
+
when Proc then fallback.call(blob)
|
|
36
|
+
when String then fallback
|
|
37
|
+
else super
|
|
38
|
+
end
|
|
9
39
|
else
|
|
10
|
-
|
|
40
|
+
super
|
|
11
41
|
end
|
|
12
42
|
end
|
|
13
43
|
|
|
14
|
-
def
|
|
15
|
-
|
|
44
|
+
def processed?
|
|
45
|
+
if blob.bucket_backed?
|
|
46
|
+
async_record&.state == "processed"
|
|
47
|
+
else
|
|
48
|
+
super
|
|
49
|
+
end
|
|
16
50
|
end
|
|
17
51
|
|
|
18
52
|
def processing?
|
|
@@ -31,29 +65,64 @@ module ActiveStorage
|
|
|
31
65
|
async_record&.error
|
|
32
66
|
end
|
|
33
67
|
|
|
68
|
+
def async_state
|
|
69
|
+
return nil unless blob.bucket_backed?
|
|
70
|
+
async_record&.state || "pending"
|
|
71
|
+
end
|
|
72
|
+
|
|
34
73
|
private
|
|
35
74
|
|
|
36
|
-
def
|
|
37
|
-
|
|
75
|
+
def resolved_async_options
|
|
76
|
+
@resolved_async_options ||=
|
|
77
|
+
variation.async_options.presence ||
|
|
78
|
+
ActiveStorage::AsyncVariants::Registry[variation.digest] ||
|
|
79
|
+
find_named_async_variant&.dig(2) ||
|
|
80
|
+
{}
|
|
38
81
|
end
|
|
39
82
|
|
|
40
|
-
def
|
|
41
|
-
|
|
83
|
+
def active_fallback
|
|
84
|
+
if failed?
|
|
85
|
+
resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
|
|
86
|
+
else
|
|
87
|
+
resolved_async_options[:processing]
|
|
88
|
+
end
|
|
42
89
|
end
|
|
43
90
|
|
|
44
|
-
|
|
45
|
-
|
|
91
|
+
# Cold-path scan: used by enqueue! (which needs the attachment +
|
|
92
|
+
# variant_name to dispatch ProcessJob) and by resolved_async_options as
|
|
93
|
+
# a fallback when the Registry is cold (e.g. in dev, when a
|
|
94
|
+
# RepresentationsRedirectController request hits a worker that hasn't
|
|
95
|
+
# autoloaded the consumer model yet).
|
|
96
|
+
#
|
|
97
|
+
# Walks one level through preview_image attachments so a request for
|
|
98
|
+
# a Variant of a video's extracted preview frame can still find the
|
|
99
|
+
# named variant declared on the parent record's source-video field.
|
|
100
|
+
def find_named_async_variant
|
|
101
|
+
target = variation.transformations.to_json
|
|
102
|
+
scan_for_named_variant(blob, target)
|
|
46
103
|
end
|
|
47
104
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
105
|
+
def scan_for_named_variant(blob_to_scan, target, depth: 0)
|
|
106
|
+
blob_to_scan.attachments.each do |attachment|
|
|
107
|
+
if attachment.name == "preview_image" && attachment.record_type == "ActiveStorage::Blob" && depth < 1
|
|
108
|
+
source = ActiveStorage::Blob.find_by(id: attachment.record_id)
|
|
109
|
+
result = source && scan_for_named_variant(source, target, depth: depth + 1)
|
|
110
|
+
return result if result
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
attachment.send(:named_variants).each do |name, _|
|
|
115
|
+
candidate = attachment.variant(name.to_sym)
|
|
116
|
+
if candidate.variation.transformations.to_json == target
|
|
117
|
+
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:processing].present?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
56
120
|
end
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def async_record
|
|
125
|
+
blob.variant_records.find_by(variation_digest: variation.digest)
|
|
57
126
|
end
|
|
58
127
|
end
|
|
59
128
|
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
5
|
module VariationExtension
|
|
6
|
-
ASYNC_KEYS = %i[
|
|
6
|
+
ASYNC_KEYS = %i[processing failed transformer].freeze
|
|
7
7
|
|
|
8
8
|
def initialize(transformations)
|
|
9
9
|
if transformations.is_a?(Hash)
|
|
@@ -13,6 +13,7 @@ module ActiveStorage
|
|
|
13
13
|
@async_options = {}
|
|
14
14
|
super
|
|
15
15
|
end
|
|
16
|
+
ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:processing].present?
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def async_options
|
|
@@ -1,37 +1,94 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "turbo-rails"
|
|
3
4
|
require_relative "async_variants/version"
|
|
5
|
+
require_relative "async_variants/helper"
|
|
4
6
|
require_relative "async_variants/transformer"
|
|
7
|
+
require_relative "async_variants/registry"
|
|
8
|
+
require_relative "async_variants/blob_extension"
|
|
5
9
|
require_relative "async_variants/variation_extension"
|
|
6
10
|
require_relative "async_variants/variant_with_record_extension"
|
|
11
|
+
require_relative "async_variants/variant_record_extension"
|
|
12
|
+
require_relative "async_variants/preview_extension"
|
|
7
13
|
require_relative "async_variants/attachment_extension"
|
|
14
|
+
require_relative "async_variants/reflection_extension"
|
|
8
15
|
require_relative "async_variants/process_job"
|
|
16
|
+
require_relative "async_variants/asset_tag_helper_extension"
|
|
9
17
|
|
|
10
18
|
module ActiveStorage
|
|
11
19
|
module AsyncVariants
|
|
20
|
+
# HTML attributes round-tripped through the state-endpoint URL so the
|
|
21
|
+
# eventual processed-state render can apply them to the inner <img>/<video>.
|
|
22
|
+
PASS_THROUGH_HTML_OPTIONS = %i[alt width height controls autoplay preload].freeze
|
|
23
|
+
|
|
24
|
+
mattr_accessor :cdn_host
|
|
25
|
+
|
|
26
|
+
# Lets the host app plug its auth chain (and thus `current_user`) into the
|
|
27
|
+
# gem's StatesController. Set to a string so resolution is deferred until
|
|
28
|
+
# the host's class is autoloadable. Defaults to ActionController::Base.
|
|
29
|
+
mattr_accessor :parent_controller, default: "ActionController::Base"
|
|
30
|
+
|
|
12
31
|
class Engine < ::Rails::Engine
|
|
32
|
+
# Prepend the core model/reflection extensions before eager_load runs
|
|
33
|
+
# so that models' has_X_attached blocks (and the Variation.wrap calls
|
|
34
|
+
# they trigger via reflection.variant) go through our hooks. The
|
|
35
|
+
# :before_eager_load load_hook fires from the eager_load! initializer
|
|
36
|
+
# in finisher_hook, after all autoload paths have been set up but
|
|
37
|
+
# before any model class is loaded.
|
|
38
|
+
# :nocov:
|
|
39
|
+
ActiveSupport.on_load(:before_eager_load) do
|
|
40
|
+
ActiveStorage::AsyncVariants.prepend_model_extensions!
|
|
41
|
+
end
|
|
42
|
+
# :nocov:
|
|
43
|
+
|
|
13
44
|
config.after_initialize do
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
ActiveStorage::AsyncVariants::
|
|
45
|
+
# Idempotent — covers eager_load=false (dev/test) where the
|
|
46
|
+
# :before_eager_load hook never fires. Models autoload lazily on
|
|
47
|
+
# demand, and we just need the extensions in place by the time
|
|
48
|
+
# the first one loads.
|
|
49
|
+
ActiveStorage::AsyncVariants.prepend_model_extensions!
|
|
50
|
+
|
|
51
|
+
ActionView::Helpers::AssetTagHelper.prepend(
|
|
52
|
+
ActiveStorage::AsyncVariants::AssetTagHelperExtension
|
|
22
53
|
)
|
|
23
54
|
end
|
|
24
55
|
end
|
|
25
56
|
|
|
57
|
+
def self.prepend_model_extensions!
|
|
58
|
+
require "active_storage/reflection"
|
|
59
|
+
ActiveStorage::Reflection::HasAttachedReflection.prepend(
|
|
60
|
+
ActiveStorage::AsyncVariants::ReflectionExtension
|
|
61
|
+
)
|
|
62
|
+
ActiveStorage::Blob.prepend(
|
|
63
|
+
ActiveStorage::AsyncVariants::BlobExtension
|
|
64
|
+
)
|
|
65
|
+
ActiveStorage::Variation.prepend(
|
|
66
|
+
ActiveStorage::AsyncVariants::VariationExtension
|
|
67
|
+
)
|
|
68
|
+
ActiveStorage::VariantWithRecord.prepend(
|
|
69
|
+
ActiveStorage::AsyncVariants::VariantWithRecordExtension
|
|
70
|
+
)
|
|
71
|
+
ActiveStorage::VariantRecord.include(
|
|
72
|
+
ActiveStorage::AsyncVariants::VariantRecordExtension
|
|
73
|
+
)
|
|
74
|
+
ActiveStorage::Attachment.prepend(
|
|
75
|
+
ActiveStorage::AsyncVariants::AttachmentExtension
|
|
76
|
+
)
|
|
77
|
+
ActiveStorage::Preview.prepend(
|
|
78
|
+
ActiveStorage::AsyncVariants::PreviewExtension
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
26
82
|
def self.callback_token_for(variant_record)
|
|
27
83
|
ActiveStorage.verifier.generate(variant_record.id, purpose: :async_variant_callback)
|
|
28
84
|
end
|
|
29
85
|
|
|
30
86
|
def self.callback_url_for(variant_record)
|
|
87
|
+
url_options = ActiveStorage::Current.url_options || Rails.application.default_url_options
|
|
31
88
|
token = callback_token_for(variant_record)
|
|
32
89
|
Rails.application.routes.url_helpers.active_storage_async_variant_callback_url(
|
|
33
90
|
token: token,
|
|
34
|
-
**
|
|
91
|
+
**url_options,
|
|
35
92
|
)
|
|
36
93
|
end
|
|
37
94
|
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.4.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-06-06 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activestorage
|
|
@@ -23,6 +23,20 @@ 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'
|
|
26
40
|
email:
|
|
27
41
|
- micah@botandrose.com
|
|
28
42
|
executables: []
|
|
@@ -39,17 +53,30 @@ files:
|
|
|
39
53
|
- README.md
|
|
40
54
|
- Rakefile
|
|
41
55
|
- app/controllers/active_storage/async_variants/callbacks_controller.rb
|
|
56
|
+
- app/controllers/active_storage/async_variants/states_controller.rb
|
|
57
|
+
- app/views/active_storage/async_variants/states/_failed.html.erb
|
|
58
|
+
- app/views/active_storage/async_variants/states/_pending.html.erb
|
|
59
|
+
- app/views/active_storage/async_variants/states/_processed.html.erb
|
|
60
|
+
- app/views/active_storage/async_variants/states/_processing.html.erb
|
|
61
|
+
- app/views/active_storage/async_variants/states/show.html.erb
|
|
42
62
|
- config/routes.rb
|
|
63
|
+
- features/state_rendering.feature
|
|
64
|
+
- features/step_definitions/dummy_steps.rb
|
|
65
|
+
- features/support/env.rb
|
|
43
66
|
- gemfiles/rails_7.2.gemfile
|
|
44
|
-
- gemfiles/rails_7.2.gemfile.lock
|
|
45
67
|
- gemfiles/rails_8.0.gemfile
|
|
46
|
-
- gemfiles/rails_8.0.gemfile.lock
|
|
47
68
|
- gemfiles/rails_8.1.gemfile
|
|
48
|
-
- gemfiles/rails_8.1.gemfile.lock
|
|
49
69
|
- lib/active_storage/async_variants.rb
|
|
70
|
+
- lib/active_storage/async_variants/asset_tag_helper_extension.rb
|
|
50
71
|
- lib/active_storage/async_variants/attachment_extension.rb
|
|
72
|
+
- lib/active_storage/async_variants/blob_extension.rb
|
|
73
|
+
- lib/active_storage/async_variants/helper.rb
|
|
74
|
+
- lib/active_storage/async_variants/preview_extension.rb
|
|
51
75
|
- lib/active_storage/async_variants/process_job.rb
|
|
76
|
+
- lib/active_storage/async_variants/reflection_extension.rb
|
|
77
|
+
- lib/active_storage/async_variants/registry.rb
|
|
52
78
|
- lib/active_storage/async_variants/transformer.rb
|
|
79
|
+
- lib/active_storage/async_variants/variant_record_extension.rb
|
|
53
80
|
- lib/active_storage/async_variants/variant_with_record_extension.rb
|
|
54
81
|
- lib/active_storage/async_variants/variation_extension.rb
|
|
55
82
|
- lib/active_storage/async_variants/version.rb
|