active_storage-crucible 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2656213e02772d6fa0094857594ad926a718ea12896b9d0398bee0daefe575f6
4
- data.tar.gz: e78728082130e9daec29363ce14551a96decb246ebed19f288dd2ba94b2cd6c3
3
+ metadata.gz: 198afc548c8b397642733f6739431743056cb4057c1c4a446d5570d68710e6d5
4
+ data.tar.gz: 192a962e2436123deb30f4c8e1ac4e691c885f18dd3da75a71a6e0785b68c1af
5
5
  SHA512:
6
- metadata.gz: 86a2e01e5d41443a985d0bbf9b8ac362cd2838427f4f96a0b507ccf6b2921d63d3d9dd967cf35ff375d2faede45b6980f10f3bacce02e5e74e5d7e8f0fec3a9f
7
- data.tar.gz: 4df7ad1ccc4bb0eaf58025f12b7785aa5247757a265d3220763700d691d80a94f3d646cb4218c8abf622128d215cbabf7d6a974626753744e9329ebc74fa78de
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`. 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.
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` 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
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
@@ -102,11 +102,15 @@ While the preview is processing, the original video URL is served as a fallback.
102
102
 
103
103
  ### Preview flow
104
104
 
105
- 1. A video preview is requested in a view
106
- 2. `PreviewExtension#process` creates placeholder blobs for the preview image and its variant
107
- 3. POSTs to Crucible's `/video/preview` endpoint with presigned URLs and a callback URL
108
- 4. Crucible extracts a frame, resizes it, uploads both the preview image and variant
109
- 5. Crucible POSTs to the callback URL to mark the variant as processed
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`.
110
114
 
111
115
  ### What gets sent to Crucible
112
116
 
@@ -132,6 +136,7 @@ While the preview is processing, the original video URL is served as a fallback.
132
136
  "preview_image_variant_url": "https://s3.example.com/variant?presigned...",
133
137
  "dimensions": "640x480",
134
138
  "rotation": 0,
139
+ "format": "webp",
135
140
  "callback_url": "https://app.example.com/active_storage/async_variants/callbacks/signed-token"
136
141
  }
137
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])
@@ -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
- 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
- })
19
+ if (video = preview_source_video(blob))
20
+ initiate_video_preview(video, blob, variant_record, options, callback_url)
45
21
  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
- })
22
+ initiate_video_variant(blob, variant_record, options, callback_url)
61
23
  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
- })
24
+ initiate_image_variant(blob, variant_record, options, callback_url)
73
25
  end
74
26
  end
75
27
 
76
- def process_preview(blob:, variation:)
77
- return if blob.preview_image.attached?
28
+ private
78
29
 
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",
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
- preview_image_blob.metadata[:analyzed] = true
87
- blob.preview_image.attach(preview_image_blob)
38
+ attachment && ActiveStorage::Blob.find_by(id: attachment.record_id)
39
+ end
88
40
 
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")
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
- 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)
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
- rotation = blob.metadata["rotation"].to_i
104
- callback_url = ActiveStorage::AsyncVariants.callback_url_for(variant_record)
105
- Client.new.post("#{endpoint}/video/preview", {
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
- 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),
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
- private
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module Crucible
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  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.2.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-08-02 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
@@ -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: []