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 +4 -4
- data/CHANGELOG.md +5 -0
- data/app/assets/javascripts/progress-bar.js +67 -1
- data/app/controllers/active_storage/async_variants/states_controller.rb +10 -1
- data/app/views/active_storage/async_variants/states/_processing.html.erb +1 -1
- data/config/routes.rb +8 -0
- data/db/migrate/20260608000001_add_created_at_to_variant_records.rb +7 -0
- data/lib/active_storage/async_variants/helper.rb +9 -7
- data/lib/active_storage/async_variants/variant_with_record_extension.rb +9 -0
- data/lib/active_storage/async_variants/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34c672d7d6d989bb7c633aa1e57e081d6879a8ea9695ffd65997b9df9cf8f8ab
|
|
4
|
+
data.tar.gz: aca54bb9b2ae6af16016f1e96e6e3f221bc7029b8eca9c145535eadefeb57d66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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?
|
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.
|
|
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
|