active_storage-async_variants 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 259c11526314988d60e78e9e176dc16105c6aeb578b1774693a666fc1e277a21
4
- data.tar.gz: 92fc3546ed708ed709ad4fedc2d7e6258d765719fad75571844671a902f1b485
3
+ metadata.gz: 34c672d7d6d989bb7c633aa1e57e081d6879a8ea9695ffd65997b9df9cf8f8ab
4
+ data.tar.gz: aca54bb9b2ae6af16016f1e96e6e3f221bc7029b8eca9c145535eadefeb57d66
5
5
  SHA512:
6
- metadata.gz: ebfe0839de2603c91a5705f3942b30b3db7685d20ce5d3d2425f98472a191d559ac9103bab5a59972e09f18385b8829283fe0323f2de4810e8619cb57e028ab2
7
- data.tar.gz: 165f5796f098a3f40194a9d893ba07b98bd7b3a7ac8601fda744f5dcb1e4a98c0d69e1514e788f612ea8fa1dbf4bca6c44cbb7ee5ef072fcf5fa13fe03ce5b56
6
+ metadata.gz: e86b3777c71d70d2014fe439fb91f395f1711cce40efefa3c5e5291a22bd2fc6d1933aad3a1c095557726e45547d7112f9e2f2bbd24fece75726adebddd6f686
7
+ data.tar.gz: 591deb4316c685316084ebb26904e5fe5b2ecbea0328cc832469ae4185388239511e35507b53264e137cf85153bfe1a846e7bd0491ba023bb991333653cfb532
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## [0.8.0]
2
+
3
+ - The processing progress bar now advances smoothly between polls: variants expose a `rate` (percent-per-second, derived from the record's `created_at`, last heartbeat, and reported progress) that the `@botandrose/progress-bar` element uses to optimistically creep forward, instead of only stepping on each refresh. Includes a migration adding a `created_at` column to `active_storage_variant_records`; run it before deploying.
4
+ - Fix: the processing/failed placeholder image now points at a tiny gem-served 1x1 GIF whose URL ends in the media's filename, instead of a `data:` URI. Restores filename-based identification of the placeholder (e.g. for media-table diff assertions) without fetching the original through a representation. Video placeholders remain a source-less `<video>`.
5
+
1
6
  ## [0.7.0]
2
7
 
3
8
  - **Breaking:** A variant now opts into async processing with `async: true` instead of `processing:`. The `processing:` and `failed:` placeholder options are removed entirely — there is nothing to configure. A variant's `.url` serves the original while pending/processing/failed, and the processed variant once ready.
@@ -1,5 +1,6 @@
1
1
  const CIRCLE_RADIUS = 40; // fallback r attribute; CSS shrinks it to fit the stroke
2
2
  const PATH_LENGTH = 100; // normalize the arc so dash math is independent of the radius
3
+ const RATE_HZ = 30; // optimistic auto-advance tick frequency
3
4
 
4
5
  const STYLES = `
5
6
  :host {
@@ -178,12 +179,21 @@ function clampPercent(value) {
178
179
  return Math.min(100, Math.max(0, number));
179
180
  }
180
181
 
182
+ function parseRate(value) {
183
+ const number = Number(value);
184
+ if (Number.isNaN(number)) {
185
+ throw new TypeError(`progress-bar: rate must be numeric, got ${JSON.stringify(value)}`);
186
+ }
187
+ return number;
188
+ }
189
+
181
190
  class ProgressBar extends HTMLElement {
182
191
  constructor() {
183
192
  super();
184
193
  this.attachShadow({ mode: 'open' });
185
194
  this._percent = null;
186
195
  this._renderedMode = null;
196
+ this._rateTimer = null;
187
197
  }
188
198
 
189
199
  connectedCallback() {
@@ -192,6 +202,11 @@ class ProgressBar extends HTMLElement {
192
202
  this.setAttribute('aria-valuemin', '0');
193
203
  this.setAttribute('aria-valuemax', '100');
194
204
  this.updateBar();
205
+ this._syncTicker();
206
+ }
207
+
208
+ disconnectedCallback() {
209
+ this._stopTicker();
195
210
  }
196
211
 
197
212
  get percent() {
@@ -214,6 +229,19 @@ class ProgressBar extends HTMLElement {
214
229
  this.toggleAttribute('error', Boolean(value));
215
230
  }
216
231
 
232
+ get rate() {
233
+ const value = this.getAttribute('rate');
234
+ return value === null ? null : Number(value);
235
+ }
236
+
237
+ set rate(value) {
238
+ if (value === null || value === undefined) {
239
+ this.removeAttribute('rate');
240
+ } else {
241
+ this.setAttribute('rate', parseRate(value));
242
+ }
243
+ }
244
+
217
245
  get indeterminate() {
218
246
  return !this.hasAttribute('percent');
219
247
  }
@@ -227,16 +255,22 @@ class ProgressBar extends HTMLElement {
227
255
  }
228
256
 
229
257
  static get observedAttributes() {
230
- return ['percent', 'mode'];
258
+ return ['percent', 'mode', 'rate'];
231
259
  }
232
260
 
233
261
  attributeChangedCallback(name, oldValue, newValue) {
234
262
  if (name === 'percent') {
235
263
  this._percent = newValue === null ? null : clampPercent(newValue);
236
264
  }
265
+ if (name === 'rate' && newValue !== null) {
266
+ parseRate(newValue);
267
+ }
237
268
  if (name === 'mode') {
238
269
  this.render();
239
270
  }
271
+ if (name === 'percent' || name === 'rate') {
272
+ this._syncTicker();
273
+ }
240
274
  this.updateBar();
241
275
  }
242
276
 
@@ -263,6 +297,38 @@ class ProgressBar extends HTMLElement {
263
297
  }
264
298
  }
265
299
 
300
+ // Optimistic auto-advance: while a nonzero rate is set on a determinate bar,
301
+ // creep percent forward on its own. Indeterminate bars ignore rate entirely.
302
+ _syncTicker() {
303
+ const rate = Number(this.getAttribute('rate'));
304
+ const shouldRun = this.isConnected && !this.indeterminate && Number.isFinite(rate) && rate !== 0;
305
+ if (shouldRun && this._rateTimer === null) {
306
+ this._rateTimer = setInterval(() => this._tick(), 1000 / RATE_HZ);
307
+ } else if (!shouldRun) {
308
+ this._stopTicker();
309
+ }
310
+ }
311
+
312
+ _stopTicker() {
313
+ if (this._rateTimer !== null) {
314
+ clearInterval(this._rateTimer);
315
+ this._rateTimer = null;
316
+ }
317
+ }
318
+
319
+ _tick() {
320
+ const rate = Number(this.getAttribute('rate'));
321
+ const next = clampPercent(this._percent + rate / RATE_HZ);
322
+ if (next !== this._percent) {
323
+ this.percent = next;
324
+ }
325
+ // Hitting the bound it's heading toward retires the rate; a later percent
326
+ // change can re-arm it.
327
+ if (next === 0 || next === 100) {
328
+ this.removeAttribute('rate');
329
+ }
330
+ }
331
+
266
332
  render() {
267
333
  const mode = this.mode;
268
334
  if (this._renderedMode === mode) {
@@ -11,11 +11,20 @@ module ActiveStorage
11
11
 
12
12
  layout false
13
13
 
14
- before_action :set_variant
14
+ # 1x1 transparent GIF served at a filename-bearing URL, so the
15
+ # processing/failed <img> identifies its media without fetching anything.
16
+ TRANSPARENT_GIF = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7".unpack1("m").freeze
17
+
18
+ before_action :set_variant, only: %i[show retry]
15
19
 
16
20
  def show
17
21
  end
18
22
 
23
+ def placeholder
24
+ expires_in 1.year, public: true
25
+ send_data TRANSPARENT_GIF, type: "image/gif", disposition: "inline"
26
+ end
27
+
19
28
  def retry
20
29
  @variant.blob.variant_records.where(
21
30
  variation_digest: @variant.variation.digest,
@@ -1,7 +1,7 @@
1
1
  <div class="async-variant-state async-variant-processing">
2
2
  <%= async_variant_placeholder_tag(variant, html_options, kind: kind) %>
3
3
 
4
- <%= content_tag "progress-bar", "", mode: "circular", class: "async-variant-progress", percent: variant.progress %>
4
+ <%= content_tag "progress-bar", "", mode: "circular", class: "async-variant-progress", percent: variant.progress, rate: variant.progress_rate %>
5
5
  </div>
6
6
 
7
7
  <%= ActiveStorage::AsyncVariants.stylesheet_link_tag "progress" %>
data/config/routes.rb CHANGED
@@ -12,5 +12,13 @@ Rails.application.routes.draw do
12
12
  to: "active_storage/async_variants/states#retry",
13
13
  as: :async_variant_state_retry
14
14
 
15
+ # Tiny 1x1 GIF whose URL carries the media's filename, so the processing/failed
16
+ # box reserves layout without fetching the original through a representation.
17
+ get "/active_storage/async_variants/placeholder/:filename",
18
+ to: "active_storage/async_variants/states#placeholder",
19
+ as: :async_variant_placeholder,
20
+ constraints: { filename: %r{[^/]+} },
21
+ format: false
22
+
15
23
  ActiveStorage::AsyncVariants::Assets.draw(self, "/active_storage/async_variants/assets")
16
24
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCreatedAtToVariantRecords < ActiveRecord::Migration[7.2]
4
+ def change
5
+ add_column :active_storage_variant_records, :created_at, :datetime, if_not_exists: true
6
+ end
7
+ end
@@ -7,16 +7,18 @@ module ActiveStorage
7
7
  # `helper ActiveStorage::AsyncVariants::Helper` (so the state partials can
8
8
  # use them).
9
9
  module Helper
10
- # 1x1 transparent GIF: reserves the variant's box without fetching the
11
- # original through the representations path on every poll.
12
- PLACEHOLDER_SRC = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
13
-
10
+ # Invisible box reserving layout for the progress bar. The image src is a
11
+ # tiny gem GIF whose URL ends in the media filename (no original fetched);
12
+ # the video placeholder carries no <source>.
14
13
  def async_variant_placeholder_tag(variant, html_options = {}, kind: :image)
15
14
  opts = async_variant_box_dimensions(variant)
16
15
  .merge(html_options.symbolize_keys.except(:src, :controls, :autoplay, :preload, :poster))
17
- # A video placeholder carries no <source>, so it reserves the box
18
- # without loading anything -- same zero-network intent as the GIF.
19
- kind == :video ? content_tag(:video, "", opts) : image_tag(PLACEHOLDER_SRC, opts)
16
+ if kind == :video
17
+ content_tag(:video, "", opts)
18
+ else
19
+ src = Rails.application.routes.url_helpers.async_variant_placeholder_path(variant.blob.filename.to_s)
20
+ image_tag(src, opts)
21
+ end
20
22
  end
21
23
 
22
24
  def async_variant_box_dimensions(variant)
@@ -75,6 +75,15 @@ module ActiveStorage
75
75
  async_record&.last_heartbeat_at
76
76
  end
77
77
 
78
+ # Percent-per-second observed so far (progress over elapsed processing
79
+ # time); drives the progress-bar's optimistic creep between reloads.
80
+ def progress_rate
81
+ record = async_record
82
+ return nil unless record&.created_at && record.last_heartbeat_at && record.progress&.positive?
83
+ elapsed = record.last_heartbeat_at - record.created_at
84
+ elapsed.positive? ? (record.progress / elapsed).round(4) : nil
85
+ end
86
+
78
87
  private
79
88
 
80
89
  def async_variant?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_storage-async_variants
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
@@ -79,6 +79,7 @@ files:
79
79
  - app/views/active_storage/async_variants/states/show.html.erb
80
80
  - config/routes.rb
81
81
  - db/migrate/20260607000001_add_heartbeat_columns_to_variant_records.rb
82
+ - db/migrate/20260608000001_add_created_at_to_variant_records.rb
82
83
  - features/retry_flow.feature
83
84
  - features/retry_visibility.feature
84
85
  - features/state_rendering.feature