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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/CLAUDE.md +13 -7
  4. data/README.md +30 -57
  5. data/Rakefile +1 -20
  6. data/app/controllers/active_storage/async_variants/callbacks_controller.rb +28 -19
  7. data/config/routes.rb +0 -17
  8. data/db/migrate/20260304000001_add_state_columns_to_variant_records.rb +9 -0
  9. data/gemfiles/rails_7.2.gemfile +0 -4
  10. data/gemfiles/rails_8.0.gemfile +0 -4
  11. data/gemfiles/rails_8.1.gemfile +0 -4
  12. data/lib/active_storage/async_variants/attachment_extension.rb +19 -2
  13. data/lib/active_storage/async_variants/blob_extension.rb +3 -1
  14. data/lib/active_storage/async_variants/named_variant_scan.rb +55 -0
  15. data/lib/active_storage/async_variants/preview_extension.rb +52 -37
  16. data/lib/active_storage/async_variants/process_job.rb +53 -17
  17. data/lib/active_storage/async_variants/reflection_extension.rb +2 -2
  18. data/lib/active_storage/async_variants/registry.rb +1 -1
  19. data/lib/active_storage/async_variants/transformer.rb +32 -1
  20. data/lib/active_storage/async_variants/variant_record_extension.rb +15 -0
  21. data/lib/active_storage/async_variants/variant_with_record_extension.rb +11 -30
  22. data/lib/active_storage/async_variants/variation_extension.rb +8 -5
  23. data/lib/active_storage/async_variants/version.rb +1 -1
  24. data/lib/active_storage/async_variants.rb +31 -34
  25. metadata +4 -47
  26. data/app/assets/javascripts/progress-bar.js +0 -347
  27. data/app/assets/javascripts/retry.js +0 -11
  28. data/app/assets/stylesheets/progress.css +0 -35
  29. data/app/assets/stylesheets/retry.css +0 -98
  30. data/app/controllers/active_storage/async_variants/states_controller.rb +0 -71
  31. data/app/views/active_storage/async_variants/states/_failed.html.erb +0 -44
  32. data/app/views/active_storage/async_variants/states/_pending.html.erb +0 -1
  33. data/app/views/active_storage/async_variants/states/_processed.html.erb +0 -5
  34. data/app/views/active_storage/async_variants/states/_processing.html.erb +0 -15
  35. data/app/views/active_storage/async_variants/states/show.html.erb +0 -10
  36. data/features/retry_flow.feature +0 -20
  37. data/features/retry_visibility.feature +0 -23
  38. data/features/state_rendering.feature +0 -40
  39. data/features/step_definitions/dummy_steps.rb +0 -144
  40. data/features/support/env.rb +0 -28
  41. data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +0 -59
  42. 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 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
- )
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? ? find_preview_variant_record.image.url(...) : fallback_preview_url(...)
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
- find_preview_variant_record.image.blob.key
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
- find_preview_variant_record&.state || "pending"
72
+ async_record&.state || "pending"
53
73
  end
54
74
 
55
75
  def progress
56
- find_preview_variant_record&.progress
76
+ async_record&.progress
57
77
  end
58
78
 
59
79
  def progress_known?
60
- !find_preview_variant_record&.progress.nil?
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
- find_preview_variant_record&.last_heartbeat_at
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[:async].present?
104
+ resolved_async_options[:transformer].present?
71
105
  end
72
106
 
73
107
  # Variations rebuilt from the redirect URL only carry transformations --
74
- # :async / :transformer are stripped at Variation#initialize and not
75
- # embedded in the URL key. Recover them via the digest-keyed registry that
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
- find_named_async_variant&.dig(2) ||
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
- find_preview_variant_record&.state == "processed"
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
- @variant = attachment.variant(variant_name.to_sym)
13
- variation = @variant.variation
14
- @async_options = variation.async_options
15
- transformer_class = @async_options[:transformer]
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
- process_default(blob, @variant_record, variation)
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, async: true
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 :async in its async_options -- which happens on
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
- self.class.instance_method(:process).owner != ActiveStorage::AsyncVariants::Transformer
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[:async].present?
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
- target = variation.transformations.to_json
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
- ASYNC_KEYS = %i[async transformer].freeze
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
- @async_options = transformations.slice(*ASYNC_KEYS)
11
- super(transformations.except(*ASYNC_KEYS))
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[:async]
19
+ ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:transformer]
17
20
  end
18
21
 
19
22
  def async_options
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
@@ -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. The turbo-frame poll matches it so
39
- # each reload lands on fresh progress; keep heartbeat_stale_after well above it.
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
- Assets = IsolateAssets.register(namespace: self, engine: Engine, route_name: :async_variant_asset)
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.8.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-06-08 00:00:00.000000000 Z
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/helper.rb
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