active_storage-crucible 0.1.1 → 0.3.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/CLAUDE.md +20 -8
- data/README.md +18 -5
- data/lib/active_storage/crucible/blob_extension.rb +8 -0
- data/lib/active_storage/crucible/client.rb +10 -1
- data/lib/active_storage/crucible/presigned_url.rb +1 -0
- data/lib/active_storage/crucible/transformer.rb +76 -93
- data/lib/active_storage/crucible/version.rb +1 -1
- data/lib/active_storage/crucible.rb +1 -0
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 198afc548c8b397642733f6739431743056cb4057c1c4a446d5570d68710e6d5
|
|
4
|
+
data.tar.gz: 192a962e2436123deb30f4c8e1ac4e691c885f18dd3da75a71a6e0785b68c1af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16631a3fc64fb178429b4f81a27503e78bf529d8b86b9aadced917a1cb4c465b30de4f23c0daf4a98e836e92d9bfce894e1d614dd0e46a272a541d393e2593fa
|
|
7
|
+
data.tar.gz: 6eae4dcfecdcfc77834bfc4cf4f0c5bde74886792f150316eb3d4c38bac8d93681627ffeab40d05e30c850b7e1fee016194cf801bc8cb0128c85a016454fa669
|
data/CLAUDE.md
CHANGED
|
@@ -19,19 +19,31 @@ bundle exec rake # Default task runs specs
|
|
|
19
19
|
The gem is a Rails Engine that prepends extensions onto Active Storage classes:
|
|
20
20
|
|
|
21
21
|
- **`Crucible` module** (`lib/active_storage/crucible.rb`) — Engine setup, configurable `endpoint` for the Crucible service
|
|
22
|
-
- **`Transformer`** (`lib/active_storage/crucible/transformer.rb`) — Inherits from `ActiveStorage::AsyncVariants::Transformer`.
|
|
23
|
-
- **`
|
|
24
|
-
- **`BlobExtension`** (`lib/active_storage/crucible/blob_extension.rb`) — Prepended onto `ActiveStorage::Blob`. Makes videos report as `variable?` and `previewable?` when Crucible is configured.
|
|
22
|
+
- **`Transformer`** (`lib/active_storage/crucible/transformer.rb`) — Inherits from `ActiveStorage::AsyncVariants::Transformer`. `#initiate` reads the variant record's blob to pick one of three routes: an image → `/image/variant`; a video → `/video/variant` (transcode); or a video's extracted-frame placeholder → `/video/preview` (the stock preview graph). It creates the placeholder output blob and generates presigned GET/PUT URLs. Crucible processes asynchronously and calls back when done.
|
|
23
|
+
- **`BlobExtension`** (`lib/active_storage/crucible/blob_extension.rb`) — Prepended onto `ActiveStorage::Blob`. Makes videos (and images) report as `variable?`, and videos as `previewable?`, when Crucible is configured — independent of a local ffmpeg, since Crucible extracts frames server-side. `#representation` routes video output formats (mp4/webm/…) to `variant` and everything else to `super`.
|
|
25
24
|
- **`Client`** (`lib/active_storage/crucible/client.rb`) — Simple `Net::HTTP` wrapper that POSTs JSON to Crucible
|
|
26
25
|
- **`PresignedUrl`** (`lib/active_storage/crucible/presigned_url.rb`) — Generates presigned S3 URLs for GET/PUT access to blobs
|
|
27
26
|
|
|
28
27
|
### Processing Flow
|
|
29
28
|
|
|
30
|
-
1. Variant defined with `transformer: ActiveStorage::Crucible::Transformer`
|
|
31
|
-
2. `async_variants`
|
|
32
|
-
3.
|
|
33
|
-
4. Crucible processes the file and POSTs back to the callback URL
|
|
34
|
-
5. `async_variants` callback controller marks variant
|
|
29
|
+
1. Variant defined with `transformer: ActiveStorage::Crucible::Transformer, async: true`
|
|
30
|
+
2. `async_variants`' `ProcessJob` resolves `attachment.representation(name)` and calls `Transformer#initiate`
|
|
31
|
+
3. `#initiate` resolves the source/output blobs, gets presigned URLs, POSTs to Crucible
|
|
32
|
+
4. Crucible processes the file(s) and POSTs back to the callback URL
|
|
33
|
+
5. `async_variants`' callback controller marks the variant processed and reconciles blob metadata
|
|
34
|
+
|
|
35
|
+
### Video Previews (stock graph)
|
|
36
|
+
|
|
37
|
+
Because videos are both `previewable?` and `variable?`, an image-format request
|
|
38
|
+
(`representation(:thumb)`) resolves to a **preview**, matching stock Active Storage:
|
|
39
|
+
the frame is persisted as the video's `preview_image` and the variant record hangs
|
|
40
|
+
off the *frame* blob. `#initiate` detects this by finding the `preview_image`
|
|
41
|
+
attachment whose blob is the variant record's blob, then POSTs `/video/preview` with
|
|
42
|
+
both `preview_image_url` (the frame) and `preview_image_variant_url` (the resized
|
|
43
|
+
output). The success callback reconciles both placeholders, so the frame is a
|
|
44
|
+
legitimate persisted preview — not an orphan. A video *output* format
|
|
45
|
+
(`representation(format: :mp4)`) instead resolves to a `variant` and transcodes via
|
|
46
|
+
`/video/variant`, with the record on the video blob.
|
|
35
47
|
|
|
36
48
|
## Test Setup
|
|
37
49
|
|
data/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Requires an S3-compatible storage service (the gem generates presigned URLs for
|
|
|
21
21
|
```ruby
|
|
22
22
|
# config/initializers/crucible.rb
|
|
23
23
|
ActiveStorage::Crucible.endpoint = "https://crucible.example.com"
|
|
24
|
+
ActiveStorage::Crucible.api_token = "..."
|
|
24
25
|
```
|
|
25
26
|
|
|
26
27
|
Or with a block:
|
|
@@ -28,9 +29,16 @@ Or with a block:
|
|
|
28
29
|
```ruby
|
|
29
30
|
ActiveStorage::Crucible.configure do |config|
|
|
30
31
|
config.endpoint = ENV["CRUCIBLE_ENDPOINT"]
|
|
32
|
+
config.api_token = ENV["CRUCIBLE_API_TOKEN"]
|
|
31
33
|
end
|
|
32
34
|
```
|
|
33
35
|
|
|
36
|
+
Crucible requires the token on every request and answers `401` without it, so
|
|
37
|
+
`api_token` must be set to the value in that Crucible deployment's encrypted
|
|
38
|
+
credentials. It belongs with the endpoint: each deployment has its own token, so
|
|
39
|
+
an app that talks to more than one (staging vs. production, say) must set the
|
|
40
|
+
pair together.
|
|
41
|
+
|
|
34
42
|
## Usage
|
|
35
43
|
|
|
36
44
|
### Image and Video Variants
|
|
@@ -94,11 +102,15 @@ While the preview is processing, the original video URL is served as a fallback.
|
|
|
94
102
|
|
|
95
103
|
### Preview flow
|
|
96
104
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
An image-format variant of a video is a stock `Preview`: the extracted frame is the video blob's `preview_image`, and the variant record hangs off that frame blob.
|
|
106
|
+
|
|
107
|
+
1. `ProcessJob` resolves the attachment's `representation`, attaches a frame placeholder as the video's `preview_image` if none exists, and creates the variant record on the frame blob
|
|
108
|
+
2. The transformer sees the record's blob is a video's `preview_image` and creates a placeholder output blob for the resized still
|
|
109
|
+
3. POSTs to Crucible's `/video/preview` endpoint with presigned PUT URLs for both the frame and the still, the dimensions, format, rotation, and a signed callback URL
|
|
110
|
+
4. Crucible extracts a frame, resizes it, and uploads both the frame and the still
|
|
111
|
+
5. Crucible POSTs to the callback URL; `async_variants` reconciles both placeholders and marks the record processed
|
|
112
|
+
|
|
113
|
+
Video-format variants of a video (transcodes) stay on the video blob and go through `/video/variant`.
|
|
102
114
|
|
|
103
115
|
### What gets sent to Crucible
|
|
104
116
|
|
|
@@ -124,6 +136,7 @@ While the preview is processing, the original video URL is served as a fallback.
|
|
|
124
136
|
"preview_image_variant_url": "https://s3.example.com/variant?presigned...",
|
|
125
137
|
"dimensions": "640x480",
|
|
126
138
|
"rotation": 0,
|
|
139
|
+
"format": "webp",
|
|
127
140
|
"callback_url": "https://app.example.com/active_storage/async_variants/callbacks/signed-token"
|
|
128
141
|
}
|
|
129
142
|
```
|
|
@@ -7,6 +7,14 @@ module ActiveStorage
|
|
|
7
7
|
super || crucible_transformable?
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# Videos are previewable whenever Crucible is configured, regardless of a
|
|
11
|
+
# local ffmpeg. Crucible extracts the frame server-side, so video->image
|
|
12
|
+
# representations deterministically take the stock preview path rather than
|
|
13
|
+
# depending on what's installed on the worker.
|
|
14
|
+
def previewable?
|
|
15
|
+
super || (video? && ActiveStorage::Crucible.endpoint.present?)
|
|
16
|
+
end
|
|
17
|
+
|
|
10
18
|
def representation(transformations)
|
|
11
19
|
variation = ActiveStorage::Variation.wrap(transformations)
|
|
12
20
|
if crucible_transformable? && video_output_format?(variation.transformations[:format])
|
|
@@ -10,7 +10,7 @@ module ActiveStorage
|
|
|
10
10
|
uri = URI.parse(url)
|
|
11
11
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
12
12
|
http.use_ssl = uri.scheme == "https"
|
|
13
|
-
request = Net::HTTP::Post.new(uri.request_uri,
|
|
13
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
14
14
|
request.body = body.to_json
|
|
15
15
|
response = http.request(request)
|
|
16
16
|
unless response.code.start_with?("2")
|
|
@@ -18,6 +18,15 @@ module ActiveStorage
|
|
|
18
18
|
end
|
|
19
19
|
response
|
|
20
20
|
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def headers
|
|
25
|
+
headers = { "Content-Type": "application/json" }
|
|
26
|
+
token = Crucible.api_token
|
|
27
|
+
headers[:Authorization] = "Bearer #{token}" if token.present?
|
|
28
|
+
headers
|
|
29
|
+
end
|
|
21
30
|
end
|
|
22
31
|
end
|
|
23
32
|
end
|
|
@@ -9,6 +9,7 @@ module ActiveStorage
|
|
|
9
9
|
blob.url(expires_in: expires_in)
|
|
10
10
|
when :put
|
|
11
11
|
service = blob.service
|
|
12
|
+
service = service.primary if service.respond_to?(:primary)
|
|
12
13
|
object = service.send(:object_for, blob.key)
|
|
13
14
|
object.presigned_url(:put, expires_in: expires_in.to_i, content_type: blob.content_type)
|
|
14
15
|
end
|
|
@@ -5,114 +5,101 @@ require "active_storage/async_variants/transformer"
|
|
|
5
5
|
module ActiveStorage
|
|
6
6
|
module Crucible
|
|
7
7
|
class Transformer < ActiveStorage::AsyncVariants::Transformer
|
|
8
|
+
# The variant_record's blob tells us which graph we're in:
|
|
9
|
+
#
|
|
10
|
+
# - a frame placeholder attached as a video's preview_image -> the stock
|
|
11
|
+
# preview path. Crucible extracts the frame (persisted as preview_image)
|
|
12
|
+
# and the resized variant in one /video/preview call.
|
|
13
|
+
# - a video blob -> the "video is variable" transcode path (/video/variant).
|
|
14
|
+
# - a plain image -> /image/variant.
|
|
8
15
|
def initiate(source_url:, callback_url:, variant_record_id:, **options)
|
|
9
16
|
variant_record = ActiveStorage::VariantRecord.find(variant_record_id)
|
|
10
17
|
blob = variant_record.blob
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
source_url = PresignedUrl.for(blob, method: :get)
|
|
16
|
-
dimensions = extract_dimensions(options)
|
|
17
|
-
|
|
18
|
-
if blob.video? && !video_output_format?(options[:format])
|
|
19
|
-
output_blob = create_output_blob(blob, variant_record, options)
|
|
20
|
-
variant_url = PresignedUrl.for(output_blob, method: :put)
|
|
21
|
-
|
|
22
|
-
preview_blob = ActiveStorage::Blob.create_before_direct_upload!(
|
|
23
|
-
filename: "#{blob.filename.base}.jpg",
|
|
24
|
-
content_type: "image/jpeg",
|
|
25
|
-
service_name: blob.service_name,
|
|
26
|
-
byte_size: 0,
|
|
27
|
-
checksum: "0",
|
|
28
|
-
)
|
|
29
|
-
preview_blob.metadata[:analyzed] = true
|
|
30
|
-
|
|
31
|
-
# Pass the requested variant format so Crucible writes the right
|
|
32
|
-
# file extension via vips and PUTs with a Content-Type that matches
|
|
33
|
-
# what we signed the variant URL for (anything else gives
|
|
34
|
-
# 403 SignatureDoesNotMatch). Crucible derives the Content-Type
|
|
35
|
-
# from `format` via Marcel so there's a single source of truth.
|
|
36
|
-
Client.new.post("#{endpoint}/video/preview", {
|
|
37
|
-
blob_url: source_url,
|
|
38
|
-
dimensions: dimensions,
|
|
39
|
-
rotation: rotation,
|
|
40
|
-
format: options[:format]&.to_s,
|
|
41
|
-
preview_image_url: PresignedUrl.for(preview_blob, method: :put),
|
|
42
|
-
preview_image_variant_url: variant_url,
|
|
43
|
-
callback_url: callback_url,
|
|
44
|
-
})
|
|
19
|
+
if (video = preview_source_video(blob))
|
|
20
|
+
initiate_video_preview(video, blob, variant_record, options, callback_url)
|
|
45
21
|
elsif blob.video?
|
|
46
|
-
|
|
47
|
-
output_blob = create_output_blob(blob, variant_record, options.merge(format: format))
|
|
48
|
-
variant_url = PresignedUrl.for(output_blob, method: :put)
|
|
49
|
-
|
|
50
|
-
# Only pass `format` -- Crucible derives Content-Type from it via
|
|
51
|
-
# the same canonical mapping output_content_type uses on this side,
|
|
52
|
-
# so the PUT header always matches what the URL was signed for.
|
|
53
|
-
Client.new.post("#{endpoint}/video/variant", {
|
|
54
|
-
blob_url: source_url,
|
|
55
|
-
variant_url: variant_url,
|
|
56
|
-
dimensions: dimensions,
|
|
57
|
-
rotation: rotation,
|
|
58
|
-
format: format,
|
|
59
|
-
callback_url: callback_url,
|
|
60
|
-
})
|
|
22
|
+
initiate_video_variant(blob, variant_record, options, callback_url)
|
|
61
23
|
else
|
|
62
|
-
|
|
63
|
-
variant_url = PresignedUrl.for(output_blob, method: :put)
|
|
64
|
-
|
|
65
|
-
Client.new.post("#{endpoint}/image/variant", {
|
|
66
|
-
blob_url: source_url,
|
|
67
|
-
variant_url: variant_url,
|
|
68
|
-
dimensions: dimensions,
|
|
69
|
-
rotation: rotation,
|
|
70
|
-
format: options[:format]&.to_s,
|
|
71
|
-
callback_url: callback_url,
|
|
72
|
-
})
|
|
24
|
+
initiate_image_variant(blob, variant_record, options, callback_url)
|
|
73
25
|
end
|
|
74
26
|
end
|
|
75
27
|
|
|
76
|
-
|
|
77
|
-
return if blob.preview_image.attached?
|
|
28
|
+
private
|
|
78
29
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
30
|
+
# When +blob+ is the extracted-frame placeholder (the video's preview_image),
|
|
31
|
+
# returns the source video blob; otherwise nil.
|
|
32
|
+
def preview_source_video(blob)
|
|
33
|
+
attachment = ActiveStorage::Attachment.find_by(
|
|
34
|
+
name: "preview_image",
|
|
35
|
+
record_type: "ActiveStorage::Blob",
|
|
36
|
+
blob_id: blob.id,
|
|
85
37
|
)
|
|
86
|
-
|
|
87
|
-
|
|
38
|
+
attachment && ActiveStorage::Blob.find_by(id: attachment.record_id)
|
|
39
|
+
end
|
|
88
40
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
41
|
+
# Crucible writes the full frame to the preview_image placeholder and the
|
|
42
|
+
# resized variant to the output blob in a single call; the success callback
|
|
43
|
+
# reconciles both (byte_size/checksum for the variant, preview_image_* for
|
|
44
|
+
# the frame).
|
|
45
|
+
def initiate_video_preview(video, frame_blob, variant_record, options, callback_url)
|
|
46
|
+
output_blob = output_blob_for(video, variant_record, options)
|
|
47
|
+
|
|
48
|
+
# Pass the requested variant format so Crucible writes the right file
|
|
49
|
+
# extension via vips and PUTs with a Content-Type that matches what we
|
|
50
|
+
# signed the variant URL for (anything else gives 403
|
|
51
|
+
# SignatureDoesNotMatch). Crucible derives the Content-Type from `format`
|
|
52
|
+
# via Marcel so there's a single source of truth.
|
|
53
|
+
Client.new.post("#{endpoint}/video/preview", {
|
|
54
|
+
blob_url: PresignedUrl.for(video, method: :get),
|
|
55
|
+
dimensions: extract_dimensions(options),
|
|
56
|
+
rotation: video.metadata["rotation"].to_i,
|
|
57
|
+
format: options[:format]&.to_s,
|
|
58
|
+
preview_image_url: PresignedUrl.for(frame_blob, method: :put),
|
|
59
|
+
preview_image_variant_url: PresignedUrl.for(output_blob, method: :put),
|
|
60
|
+
callback_url: callback_url,
|
|
61
|
+
})
|
|
62
|
+
end
|
|
92
63
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
service_name: blob.service_name,
|
|
97
|
-
byte_size: 0,
|
|
98
|
-
checksum: "0",
|
|
99
|
-
)
|
|
100
|
-
variant_blob.metadata[:analyzed] = true
|
|
101
|
-
variant_record.image.attach(variant_blob)
|
|
64
|
+
def initiate_video_variant(blob, variant_record, options, callback_url)
|
|
65
|
+
format = blob.metadata["video_format"] || options[:format].to_s
|
|
66
|
+
output_blob = output_blob_for(blob, variant_record, options.merge(format: format))
|
|
102
67
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
68
|
+
# Only pass `format` -- Crucible derives Content-Type from it via the same
|
|
69
|
+
# canonical mapping output_content_type uses on this side, so the PUT
|
|
70
|
+
# header always matches what the URL was signed for.
|
|
71
|
+
Client.new.post("#{endpoint}/video/variant", {
|
|
106
72
|
blob_url: PresignedUrl.for(blob, method: :get),
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
73
|
+
variant_url: PresignedUrl.for(output_blob, method: :put),
|
|
74
|
+
dimensions: extract_dimensions(options),
|
|
75
|
+
rotation: blob.metadata["rotation"].to_i,
|
|
76
|
+
format: format,
|
|
111
77
|
callback_url: callback_url,
|
|
112
78
|
})
|
|
113
79
|
end
|
|
114
80
|
|
|
115
|
-
|
|
81
|
+
def initiate_image_variant(blob, variant_record, options, callback_url)
|
|
82
|
+
output_blob = output_blob_for(blob, variant_record, options)
|
|
83
|
+
|
|
84
|
+
Client.new.post("#{endpoint}/image/variant", {
|
|
85
|
+
blob_url: PresignedUrl.for(blob, method: :get),
|
|
86
|
+
variant_url: PresignedUrl.for(output_blob, method: :put),
|
|
87
|
+
dimensions: extract_dimensions(options),
|
|
88
|
+
rotation: blob.metadata["rotation"].to_i,
|
|
89
|
+
format: options[:format]&.to_s,
|
|
90
|
+
callback_url: callback_url,
|
|
91
|
+
})
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A fresh blob would purge this one before an in-flight run writes to it, orphaning the file.
|
|
95
|
+
# The row lock makes a racing run reuse that blob too, and a purged record raise instead of gaining an orphan.
|
|
96
|
+
def output_blob_for(blob, variant_record, options)
|
|
97
|
+
variant_record.with_lock do
|
|
98
|
+
existing = variant_record.image.blob
|
|
99
|
+
next existing if existing&.content_type == output_content_type(options)
|
|
100
|
+
create_output_blob(blob, variant_record, options)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
116
103
|
|
|
117
104
|
def create_output_blob(blob, variant_record, options)
|
|
118
105
|
output_blob = ActiveStorage::Blob.create_before_direct_upload!(
|
|
@@ -149,10 +136,6 @@ module ActiveStorage
|
|
|
149
136
|
else "application/octet-stream"
|
|
150
137
|
end
|
|
151
138
|
end
|
|
152
|
-
|
|
153
|
-
def video_output_format?(format)
|
|
154
|
-
format.to_s.in?(%w[mp4 webm mov avi mkv])
|
|
155
|
-
end
|
|
156
139
|
end
|
|
157
140
|
end
|
|
158
141
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_storage-crucible
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.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
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0'
|
|
32
|
+
version: '0.10'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '0'
|
|
39
|
+
version: '0.10'
|
|
40
40
|
email:
|
|
41
41
|
- micah@botandrose.com
|
|
42
42
|
executables: []
|