active_storage-crucible 0.1.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 +7 -0
- data/.github/workflows/ci.yml +28 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Appraisals +11 -0
- data/CLAUDE.md +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +143 -0
- data/Rakefile +8 -0
- data/gemfiles/rails_7.2.gemfile +14 -0
- data/gemfiles/rails_8.0.gemfile +14 -0
- data/gemfiles/rails_8.1.gemfile +14 -0
- data/lib/active_storage/crucible/blob_extension.rb +30 -0
- data/lib/active_storage/crucible/client.rb +23 -0
- data/lib/active_storage/crucible/presigned_url.rb +18 -0
- data/lib/active_storage/crucible/transformer.rb +158 -0
- data/lib/active_storage/crucible/version.rb +7 -0
- data/lib/active_storage/crucible.rb +25 -0
- metadata +87 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9763344d4e3be738a0841cc75dec7ea5072883274c7f5a56eb71ff4b2ba53981
|
|
4
|
+
data.tar.gz: 00e8ebe2a8df40a423a77744f886ea46b31fa686798979d7438fa025f6df17ab
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c04894bc2165844b26a65e47096c0199bae1b3ff123597c9d0db5dcccff460c18e24f663fdb141b44b37e91d0bc2221c17139f0824df09c5e4e98138ea1b2009
|
|
7
|
+
data.tar.gz: 49deabd5e626ae306d2dca49e3906fd0580bbf186977e2ccc821bf8226c4ce003d9e237470687709b2bdb28f6057cbac762de0279ecfc110dafd1371fd115f33
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [master]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
ruby: ["3.3", "3.4", "4.0"]
|
|
17
|
+
appraisal: ["rails-7.2", "rails-8.0", "rails-8.1"]
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- uses: ruby/setup-ruby@v1
|
|
23
|
+
with:
|
|
24
|
+
ruby-version: ${{ matrix.ruby }}
|
|
25
|
+
bundler-cache: true
|
|
26
|
+
|
|
27
|
+
- run: bundle exec appraisal ${{ matrix.appraisal }} bundle install
|
|
28
|
+
- run: bundle exec appraisal ${{ matrix.appraisal }} rspec
|
data/.ruby-gemset
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
active_storage-crucible
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby-3.4.2
|
data/Appraisals
ADDED
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
A Rails gem that bridges Active Storage with the Crucible image/video processing service. Provides an `AsyncVariants` transformer that delegates variant processing to Crucible via HTTP, plus video preview support and presigned S3 URL generation. Depends on `active_storage-async_variants` (from GitHub).
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bundle exec rspec # Run all specs
|
|
13
|
+
bundle exec rspec spec/active_storage/crucible_spec.rb # Run specific spec file
|
|
14
|
+
bundle exec rake # Default task runs specs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
The gem is a Rails Engine that prepends extensions onto Active Storage classes:
|
|
20
|
+
|
|
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`. Creates a placeholder output blob, generates presigned GET/PUT URLs, then POSTs to Crucible's `/image/variant` or `/video/variant` endpoint. Crucible processes asynchronously and calls back when done.
|
|
23
|
+
- **`PreviewExtension`** (`lib/active_storage/crucible/preview_extension.rb`) — Prepended onto `ActiveStorage::Preview`. For video blobs on S3, creates placeholder blobs and POSTs to `/video/preview`. Returns the original blob URL as fallback while processing.
|
|
24
|
+
- **`BlobExtension`** (`lib/active_storage/crucible/blob_extension.rb`) — Prepended onto `ActiveStorage::Blob`. Makes videos report as `variable?` and `previewable?` when Crucible is configured.
|
|
25
|
+
- **`Client`** (`lib/active_storage/crucible/client.rb`) — Simple `Net::HTTP` wrapper that POSTs JSON to Crucible
|
|
26
|
+
- **`PresignedUrl`** (`lib/active_storage/crucible/presigned_url.rb`) — Generates presigned S3 URLs for GET/PUT access to blobs
|
|
27
|
+
|
|
28
|
+
### Processing Flow
|
|
29
|
+
|
|
30
|
+
1. Variant defined with `transformer: ActiveStorage::Crucible::Transformer`
|
|
31
|
+
2. `async_variants` enqueues a `ProcessJob` which calls `Transformer#initiate`
|
|
32
|
+
3. Transformer creates placeholder blob, gets presigned URLs, POSTs to Crucible
|
|
33
|
+
4. Crucible processes the file and POSTs back to the callback URL
|
|
34
|
+
5. `async_variants` callback controller marks variant as processed
|
|
35
|
+
|
|
36
|
+
## Test Setup
|
|
37
|
+
|
|
38
|
+
- RSpec with `rspec-rails`, SQLite3 in-memory database
|
|
39
|
+
- Dummy Rails app in `spec/dummy/` with a `User` model having `avatar` and `video` attachments
|
|
40
|
+
- Test schema defined in `spec/support/active_record.rb`
|
|
41
|
+
- Tests mock `Client.post` and `PresignedUrl.for` to avoid real HTTP/S3 calls
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Micah Geisel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# ActiveStorage::Crucible
|
|
2
|
+
|
|
3
|
+
An Active Storage transformer that sends image and video variant processing to the [Crucible](https://github.com/botandrose/crucible) web service. Built on top of [active_storage-async_variants](https://github.com/botandrose/active_storage-async_variants).
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
Processing image variants and video previews on your Rails server ties up workers and requires installing tools like `vips` and `ffmpeg` in production. Crucible is an external service that handles these transformations -- but you need a bridge between Active Storage's variant system and Crucible's HTTP API.
|
|
8
|
+
|
|
9
|
+
This gem provides that bridge. It implements the `async_variants` external transformer interface, delegating all image/video processing to Crucible via presigned S3 URLs.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "active_storage-crucible"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires an S3-compatible storage service (the gem generates presigned URLs for Crucible to read source files and write results).
|
|
18
|
+
|
|
19
|
+
### Configuration
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# config/initializers/crucible.rb
|
|
23
|
+
ActiveStorage::Crucible.endpoint = "https://crucible.example.com"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or with a block:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
ActiveStorage::Crucible.configure do |config|
|
|
30
|
+
config.endpoint = ENV["CRUCIBLE_ENDPOINT"]
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### Image and Video Variants
|
|
37
|
+
|
|
38
|
+
Use `ActiveStorage::Crucible::Transformer` as the transformer for any variant:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class User < ApplicationRecord
|
|
42
|
+
has_one_attached :avatar do |attachable|
|
|
43
|
+
attachable.variant :thumb,
|
|
44
|
+
resize_to_limit: [100, 100],
|
|
45
|
+
format: :webp,
|
|
46
|
+
transformer: ActiveStorage::Crucible::Transformer,
|
|
47
|
+
fallback: :original
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
has_one_attached :video do |attachable|
|
|
51
|
+
attachable.variant :web,
|
|
52
|
+
resize_to_limit: [1280, 720],
|
|
53
|
+
format: :webp,
|
|
54
|
+
transformer: ActiveStorage::Crucible::Transformer,
|
|
55
|
+
fallback: :original
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The transformer auto-detects image vs. video based on the blob's content type and calls the appropriate Crucible endpoint (`/image/variant` or `/video/variant`).
|
|
61
|
+
|
|
62
|
+
In views, use standard Active Storage helpers:
|
|
63
|
+
|
|
64
|
+
```erb
|
|
65
|
+
<%= image_tag user.avatar.variant(:thumb).url %>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
While the variant is processing, this serves the original file. Once Crucible finishes and calls back, it serves the processed variant.
|
|
69
|
+
|
|
70
|
+
### Video Previews
|
|
71
|
+
|
|
72
|
+
The gem also extends `ActiveStorage::Preview` to process video previews through Crucible. This happens automatically for video blobs on S3-compatible services -- no extra configuration needed.
|
|
73
|
+
|
|
74
|
+
```erb
|
|
75
|
+
<%= image_tag user.video.preview(resize_to_limit: [640, 480], format: :webp).url %>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
While the preview is processing, the original video URL is served as a fallback.
|
|
79
|
+
|
|
80
|
+
## How It Works
|
|
81
|
+
|
|
82
|
+
### Variant flow
|
|
83
|
+
|
|
84
|
+
1. A file is attached to a model with a Crucible-backed variant defined
|
|
85
|
+
2. `async_variants` enqueues a background job for the variant
|
|
86
|
+
3. The job calls `Crucible::Transformer#initiate`, which:
|
|
87
|
+
- Creates a placeholder output blob in the database
|
|
88
|
+
- Attaches it to the variant record
|
|
89
|
+
- Generates presigned GET/PUT URLs for the source and output blobs
|
|
90
|
+
- POSTs to Crucible with the URLs, dimensions, format, and a signed callback URL
|
|
91
|
+
4. Crucible processes the image/video, uploads the result to the presigned PUT URL
|
|
92
|
+
5. Crucible POSTs to the callback URL with `{"status": "success"}`
|
|
93
|
+
6. The `async_variants` callback controller marks the variant record as processed
|
|
94
|
+
|
|
95
|
+
### Preview flow
|
|
96
|
+
|
|
97
|
+
1. A video preview is requested in a view
|
|
98
|
+
2. `PreviewExtension#process` creates placeholder blobs for the preview image and its variant
|
|
99
|
+
3. POSTs to Crucible's `/video/preview` endpoint with presigned URLs and a callback URL
|
|
100
|
+
4. Crucible extracts a frame, resizes it, uploads both the preview image and variant
|
|
101
|
+
5. Crucible POSTs to the callback URL to mark the variant as processed
|
|
102
|
+
|
|
103
|
+
### What gets sent to Crucible
|
|
104
|
+
|
|
105
|
+
**Variant requests** (`POST /image/variant` or `/video/variant`):
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"blob_url": "https://s3.example.com/source?presigned...",
|
|
110
|
+
"variant_url": "https://s3.example.com/output?presigned...",
|
|
111
|
+
"dimensions": "100x100",
|
|
112
|
+
"rotation": 0,
|
|
113
|
+
"format": "webp",
|
|
114
|
+
"callback_url": "https://app.example.com/active_storage/async_variants/callbacks/signed-token"
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Preview requests** (`POST /video/preview`):
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"blob_url": "https://s3.example.com/source?presigned...",
|
|
123
|
+
"preview_image_url": "https://s3.example.com/preview?presigned...",
|
|
124
|
+
"preview_image_variant_url": "https://s3.example.com/variant?presigned...",
|
|
125
|
+
"dimensions": "640x480",
|
|
126
|
+
"rotation": 0,
|
|
127
|
+
"callback_url": "https://app.example.com/active_storage/async_variants/callbacks/signed-token"
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Callbacks
|
|
132
|
+
|
|
133
|
+
Callbacks are handled by `active_storage-async_variants`, not this gem. The callback endpoint is auto-mounted at:
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
POST /active_storage/async_variants/callbacks/:token
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Crucible must POST `{"status": "success"}` or `{"status": "failed", "error": "..."}` to this URL after processing. The token is signed -- no authentication headers are needed. The endpoint must be publicly reachable by the Crucible service.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal"
|
|
6
|
+
gem "irb"
|
|
7
|
+
gem "rake", "~> 13.0"
|
|
8
|
+
gem "rspec", "~> 3.0"
|
|
9
|
+
gem "rspec-rails"
|
|
10
|
+
gem "simplecov"
|
|
11
|
+
gem "sqlite3"
|
|
12
|
+
gem "rails", "~> 7.2.0"
|
|
13
|
+
|
|
14
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal"
|
|
6
|
+
gem "irb"
|
|
7
|
+
gem "rake", "~> 13.0"
|
|
8
|
+
gem "rspec", "~> 3.0"
|
|
9
|
+
gem "rspec-rails"
|
|
10
|
+
gem "simplecov"
|
|
11
|
+
gem "sqlite3"
|
|
12
|
+
gem "rails", "~> 8.0.0"
|
|
13
|
+
|
|
14
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "appraisal"
|
|
6
|
+
gem "irb"
|
|
7
|
+
gem "rake", "~> 13.0"
|
|
8
|
+
gem "rspec", "~> 3.0"
|
|
9
|
+
gem "rspec-rails"
|
|
10
|
+
gem "simplecov"
|
|
11
|
+
gem "sqlite3"
|
|
12
|
+
gem "rails", "~> 8.1.0"
|
|
13
|
+
|
|
14
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module Crucible
|
|
5
|
+
module BlobExtension
|
|
6
|
+
def variable?
|
|
7
|
+
super || crucible_transformable?
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def representation(transformations)
|
|
11
|
+
variation = ActiveStorage::Variation.wrap(transformations)
|
|
12
|
+
if crucible_transformable? && video_output_format?(variation.transformations[:format])
|
|
13
|
+
variant transformations
|
|
14
|
+
else
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def crucible_transformable?
|
|
22
|
+
(image? || video?) && ActiveStorage::Crucible.endpoint.present?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def video_output_format?(format)
|
|
26
|
+
format.to_s.in?(%w[mp4 webm mov avi mkv])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ActiveStorage
|
|
7
|
+
module Crucible
|
|
8
|
+
class Client
|
|
9
|
+
def post(url, body)
|
|
10
|
+
uri = URI.parse(url)
|
|
11
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
12
|
+
http.use_ssl = uri.scheme == "https"
|
|
13
|
+
request = Net::HTTP::Post.new(uri.request_uri, "Content-Type": "application/json")
|
|
14
|
+
request.body = body.to_json
|
|
15
|
+
response = http.request(request)
|
|
16
|
+
unless response.code.start_with?("2")
|
|
17
|
+
raise "Crucible request failed: #{response.code} #{response.body}"
|
|
18
|
+
end
|
|
19
|
+
response
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module Crucible
|
|
5
|
+
module PresignedUrl
|
|
6
|
+
def self.for(blob, method: :put, expires_in: 1.hour)
|
|
7
|
+
case method
|
|
8
|
+
when :get
|
|
9
|
+
blob.url(expires_in: expires_in)
|
|
10
|
+
when :put
|
|
11
|
+
service = blob.service
|
|
12
|
+
object = service.send(:object_for, blob.key)
|
|
13
|
+
object.presigned_url(:put, expires_in: expires_in.to_i, content_type: blob.content_type)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_storage/async_variants/transformer"
|
|
4
|
+
|
|
5
|
+
module ActiveStorage
|
|
6
|
+
module Crucible
|
|
7
|
+
class Transformer < ActiveStorage::AsyncVariants::Transformer
|
|
8
|
+
def initiate(source_url:, callback_url:, variant_record_id:, **options)
|
|
9
|
+
variant_record = ActiveStorage::VariantRecord.find(variant_record_id)
|
|
10
|
+
blob = variant_record.blob
|
|
11
|
+
|
|
12
|
+
rotation = blob.metadata["rotation"].to_i
|
|
13
|
+
video_format = blob.metadata["video_format"]
|
|
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
|
+
})
|
|
45
|
+
elsif blob.video?
|
|
46
|
+
format = video_format || options[:format].to_s
|
|
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
|
+
})
|
|
61
|
+
else
|
|
62
|
+
output_blob = create_output_blob(blob, variant_record, options)
|
|
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
|
+
})
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def process_preview(blob:, variation:)
|
|
77
|
+
return if blob.preview_image.attached?
|
|
78
|
+
|
|
79
|
+
preview_image_blob = ActiveStorage::Blob.create_before_direct_upload!(
|
|
80
|
+
filename: "#{blob.filename.base}.jpg",
|
|
81
|
+
content_type: "image/jpeg",
|
|
82
|
+
service_name: blob.service_name,
|
|
83
|
+
byte_size: 0,
|
|
84
|
+
checksum: "0",
|
|
85
|
+
)
|
|
86
|
+
preview_image_blob.metadata[:analyzed] = true
|
|
87
|
+
blob.preview_image.attach(preview_image_blob)
|
|
88
|
+
|
|
89
|
+
variant_record = preview_image_blob.variant_records.create_or_find_by!(variation_digest: variation.digest)
|
|
90
|
+
return if variant_record.state.in?(%w[processing processed])
|
|
91
|
+
variant_record.update!(state: "processing")
|
|
92
|
+
|
|
93
|
+
variant_blob = ActiveStorage::Blob.create_before_direct_upload!(
|
|
94
|
+
filename: "#{blob.filename.base}.#{variation.format}",
|
|
95
|
+
content_type: variation.content_type,
|
|
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)
|
|
102
|
+
|
|
103
|
+
rotation = blob.metadata["rotation"].to_i
|
|
104
|
+
callback_url = ActiveStorage::AsyncVariants.callback_url_for(variant_record)
|
|
105
|
+
Client.new.post("#{endpoint}/video/preview", {
|
|
106
|
+
blob_url: PresignedUrl.for(blob, method: :get),
|
|
107
|
+
dimensions: extract_dimensions(variation.transformations),
|
|
108
|
+
rotation: rotation,
|
|
109
|
+
preview_image_url: PresignedUrl.for(preview_image_blob, method: :put),
|
|
110
|
+
preview_image_variant_url: PresignedUrl.for(variant_blob, method: :put),
|
|
111
|
+
callback_url: callback_url,
|
|
112
|
+
})
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def create_output_blob(blob, variant_record, options)
|
|
118
|
+
output_blob = ActiveStorage::Blob.create_before_direct_upload!(
|
|
119
|
+
filename: "#{blob.filename.base}.#{options[:format] || blob.filename.extension}",
|
|
120
|
+
content_type: output_content_type(options),
|
|
121
|
+
service_name: blob.service_name,
|
|
122
|
+
byte_size: 0,
|
|
123
|
+
checksum: "0",
|
|
124
|
+
)
|
|
125
|
+
output_blob.metadata[:analyzed] = true
|
|
126
|
+
variant_record.image.attach(output_blob)
|
|
127
|
+
output_blob
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def endpoint
|
|
131
|
+
ActiveStorage::Crucible.endpoint
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def extract_dimensions(options)
|
|
135
|
+
resize = options[:resize_to_limit] || options[:resize_to_fit] || options[:resize_to_fill]
|
|
136
|
+
return nil unless resize
|
|
137
|
+
width, height = resize
|
|
138
|
+
"#{width}x#{height}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def output_content_type(options)
|
|
142
|
+
case options[:format]&.to_s
|
|
143
|
+
when "webp" then "image/webp"
|
|
144
|
+
when "png" then "image/png"
|
|
145
|
+
when "jpg", "jpeg" then "image/jpeg"
|
|
146
|
+
when "gif" then "image/gif"
|
|
147
|
+
when "mp4" then "video/mp4"
|
|
148
|
+
when "webm" then "video/webm"
|
|
149
|
+
else "application/octet-stream"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def video_output_format?(format)
|
|
154
|
+
format.to_s.in?(%w[mp4 webm mov avi mkv])
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "crucible/version"
|
|
4
|
+
require_relative "crucible/client"
|
|
5
|
+
require_relative "crucible/presigned_url"
|
|
6
|
+
require_relative "crucible/transformer"
|
|
7
|
+
require_relative "crucible/blob_extension"
|
|
8
|
+
|
|
9
|
+
module ActiveStorage
|
|
10
|
+
module Crucible
|
|
11
|
+
mattr_accessor :endpoint
|
|
12
|
+
|
|
13
|
+
def self.configure
|
|
14
|
+
yield self
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Engine < ::Rails::Engine
|
|
18
|
+
config.after_initialize do
|
|
19
|
+
ActiveStorage::Blob.prepend(
|
|
20
|
+
ActiveStorage::Crucible::BlobExtension
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: active_storage-crucible
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Micah Geisel
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-06-06 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activestorage
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: active_storage-async_variants
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
email:
|
|
41
|
+
- micah@botandrose.com
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- ".github/workflows/ci.yml"
|
|
47
|
+
- ".ruby-gemset"
|
|
48
|
+
- ".ruby-version"
|
|
49
|
+
- Appraisals
|
|
50
|
+
- CLAUDE.md
|
|
51
|
+
- LICENSE.txt
|
|
52
|
+
- README.md
|
|
53
|
+
- Rakefile
|
|
54
|
+
- gemfiles/rails_7.2.gemfile
|
|
55
|
+
- gemfiles/rails_8.0.gemfile
|
|
56
|
+
- gemfiles/rails_8.1.gemfile
|
|
57
|
+
- lib/active_storage/crucible.rb
|
|
58
|
+
- lib/active_storage/crucible/blob_extension.rb
|
|
59
|
+
- lib/active_storage/crucible/client.rb
|
|
60
|
+
- lib/active_storage/crucible/presigned_url.rb
|
|
61
|
+
- lib/active_storage/crucible/transformer.rb
|
|
62
|
+
- lib/active_storage/crucible/version.rb
|
|
63
|
+
homepage: https://github.com/botandrose/active_storage-crucible
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
homepage_uri: https://github.com/botandrose/active_storage-crucible
|
|
68
|
+
source_code_uri: https://github.com/botandrose/active_storage-crucible
|
|
69
|
+
changelog_uri: https://github.com/botandrose/active_storage-crucible/blob/master/CHANGELOG.md
|
|
70
|
+
rdoc_options: []
|
|
71
|
+
require_paths:
|
|
72
|
+
- lib
|
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: 3.2.0
|
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0'
|
|
83
|
+
requirements: []
|
|
84
|
+
rubygems_version: 3.6.2
|
|
85
|
+
specification_version: 4
|
|
86
|
+
summary: Active Storage transformer for the Crucible image/video processing service.
|
|
87
|
+
test_files: []
|