kithe 2.3.0 → 2.6.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: 5f1252c0becbda9f754e23af2333aaae66cf40cbfd6bacdad0f97425cd317de6
4
- data.tar.gz: 8460f704869d27d5a2011f746e88b9016fde465d1cbb023c883fe600b0510c1d
3
+ metadata.gz: f0ca2d6002aec0accb7264e06f3abcb71f33cf5673fa3ef27bfa035cbca67195
4
+ data.tar.gz: 9c5ffce36b1ff7a318059babe307627e23ccc00f6997f22a1fdc0d547cfd8a0f
5
5
  SHA512:
6
- metadata.gz: '0890b072c3bfb9901f4c910fcaf0f288dd72dc8c8164202998dfabf2db4b3f7a09dfeaa8df6c4c9cd242b0c98ec084c48f5214d4a2c8f4991ee6d08643495857'
7
- data.tar.gz: e7c2bbe6f3f32ce67c646289f038a06d51f371fdb87634657296546c84fe3cb52a216b0a3c6648669f73e7d61f51b56a3afb0d28584fc85950dbf0936d6f5eda
6
+ metadata.gz: 42129dea71f6944d2a70ca69cde8c5f1802c8915ce32cf1b237dbc6223edfcd9cefe776255cb2321c1978da8906a322e88ca443240915b6b2ee1eee53063cd42
7
+ data.tar.gz: 44478b610e5eb8abda0b385932fb8528c3f6b962f77be9168391d940dac26b739c4c9ec81f1b0912123f178c19b2e2837b1cc6e68483b7c8c59d67e40f1bb8dc
@@ -0,0 +1,186 @@
1
+ require 'tty/command'
2
+ require 'json'
3
+
4
+ module Kithe
5
+ # Characterizes Audio or Video files using `ffprobe`, a tool that comes with `ffmpeg`.
6
+ #
7
+ # You can pass in a local File object (with a pathname), a local String pathname, or
8
+ # a remote URL. (Remote URLs will be passed directy to ffprobe, which can efficiently
9
+ # fetch just the bytes it needs)
10
+ #
11
+ # You can get back normalized A/V metadata:
12
+ #
13
+ # metadata = FfprobeCharacterization.new(url).normalized_metadata
14
+ #
15
+ # Normalized metadata is a *flat* hash of typed JSON-able values. It uses
16
+ # keys based on what the ActiveEncode gem seems to use, but adds some extras
17
+ # and makes a few tweaks. See the #normalized_metadata method source for
18
+ # keys supplied.
19
+ #
20
+ # Or the complete FFprobe response as JSON. (We try to use ffprobe options that
21
+ # are exhausitive as to what is returned, including ffprobe version(s))
22
+ #
23
+ # ffprobe_results = FfprobeCharacterization.new(url).ffprobe_hash
24
+ #
25
+ # The class method .characterize_from_uploader can usefully extract a URL if possible
26
+ # or else execute with a file, such as from a shrine `add_metadata` block.
27
+ #
28
+ # add_metadata do |source_io, **context|
29
+ # Kithe::FfprobeCharacterization.characterize_from_uploader(source_io, context)
30
+ # end
31
+ #
32
+ class FfprobeCharacterization
33
+ class_attribute :ffprobe_command, default: "ffprobe"
34
+ class_attribute :ffprobe_timeout, default: 10
35
+
36
+ attr_reader :input_arg
37
+
38
+ # @param input [String,File] local File OR local filepath as String, OR remote URL as string
39
+ # If you have a remote url, just passing hte remote url is way more performant than
40
+ # downloading it yourself locally -- ffprobe will just fetch the bytes it needs.
41
+ def initialize(input)
42
+ if input.respond_to?(:path)
43
+ input = input.path
44
+ end
45
+ @input_arg = input
46
+ end
47
+
48
+ # a helper for creating a block for shrine uploader, you can always use
49
+ # FFprobeCharecterization.new directly too!
50
+ #
51
+ # * Does not run on "cache" action, only on promotion (or manual execution).
52
+ #
53
+ # * Will run only on items with "audio/" or "video/" content-type.
54
+ #
55
+ # * By default only on main original, not derivatives, although
56
+ # you can pass `run_on_derivatives: true` if desired.
57
+ #
58
+ # Will use ffprobe with direct URL if possible based on source_io (ffprobe
59
+ # can very efficiently access only bytes needed from URL), otherwise will
60
+ # download local temp copy if necessary.
61
+ #
62
+ # class AssetUploader < Kithe::AssetUploader
63
+ # add_metadata do |source_io, **context|
64
+ # Kithe::FfprobeCharacterization.characterize_from_uploader(source_io, context)
65
+ # end
66
+ #
67
+ # #...
68
+ # end
69
+ #
70
+ def self.characterize_from_uploader(source_io, add_metadata_context, run_on_derivatives: false)
71
+ # only for A/V please
72
+ return {} unless add_metadata_context.dig(:metadata, "mime_type")&.start_with?(%r{\A(audio|video)/})
73
+
74
+ # don't run on cache, only on promotion or manual trigger
75
+ return {} unless add_metadata_context[:action] != :cache
76
+
77
+ # don't run on derivatives unless option given
78
+ return {} unless add_metadata_context[:derivative].nil? || run_on_derivatives
79
+
80
+ # ffprobe can use a URL and very efficiently only retrieve what bytes it needs...
81
+ if source_io.respond_to?(:url) && source_io.url.start_with?(/\Ahttps?:/)
82
+ Kithe::FfprobeCharacterization.new(source_io.url).normalized_metadata
83
+ else
84
+ # if not already a file, will download, possibly slow, but gets us to go.
85
+ Shrine.with_file(source_io) do |file|
86
+ Kithe::FfprobeCharacterization.new(file.path).normalized_metadata
87
+ end
88
+ end
89
+ end
90
+
91
+ # ffprobe args come from this suggestion:
92
+ #
93
+ # https://gist.github.com/nrk/2286511?permalink_comment_id=2593200#gistcomment-2593200
94
+ #
95
+ # We also add in various current version tags! If we're going to record all ffprobe
96
+ # output, we'll want that too!
97
+ def ffprobe_options
98
+ [
99
+ "-hide_banner",
100
+ "-loglevel", "fatal",
101
+ "-show_error", "-show_format", "-show_streams", "-show_programs",
102
+ "-show_chapters", "-show_private_data", "-show_versions",
103
+ "-print_format", "json",
104
+ ]
105
+ end
106
+
107
+ # ffprobe output parsed as JSON...
108
+ def ffprobe_hash
109
+ @ffprobe_hash ||= JSON.parse(ffprobe_stdout).merge(
110
+ "ffprobe_options_used" => ffprobe_options.join(" ")
111
+ )
112
+ end
113
+
114
+ # Returns a FLAT JSON-able hash of normalized a/v metadata.
115
+ #
116
+ # Tries to standardize to what ActiveEncode uses, with some changes and additions.
117
+ # https://github.com/samvera-labs/active_encode/blob/42f5ed5427a39e56093a5e82123918c4b2619a47/lib/active_encode/technical_metadata.rb
118
+ #
119
+ # A video file or other container can have more than one audio or video stream in it, although
120
+ # this is somewhat unusual for our domain. For the stream-specific audio_ and video_ metadata
121
+ # returned, we just choose the *first* returned audio or video stream (which may be more or
122
+ # less arbitrary)
123
+ #
124
+ # See also #ffprobe_hash for complete ffprobe results
125
+ def normalized_metadata
126
+ # overall audio_sample_rate are null, audio codec is wrong
127
+ @normalized_metadata ||= {
128
+ "width" => first_video_stream_json&.dig("width"),
129
+ "height" => first_video_stream_json&.dig("height"),
130
+ "frame_rate" => video_frame_rate_as_float, # frames per second
131
+ "duration_seconds" => ffprobe_hash&.dig("format", "duration")&.to_f&.round(3),
132
+ "audio_codec" => first_audio_stream_json&.dig("codec_name"),
133
+ "video_codec" => first_video_stream_json&.dig("codec_name"),
134
+ "audio_bitrate" => first_audio_stream_json&.dig("bit_rate")&.to_i, # in bps
135
+ "video_bitrate" => first_video_stream_json&.dig("bit_rate")&.to_i, # in bps
136
+ # extra ones not ActiveEncode
137
+ "bitrate" => ffprobe_hash.dig("format", "bit_rate")&.to_i, # overall bitrate of whole file in bps
138
+ "audio_sample_rate" => first_audio_stream_json&.dig("sample_rate")&.to_i, # in Hz
139
+ "audio_channels" => first_audio_stream_json&.dig("channels")&.to_i, # usually 1 or 2 (for stereo)
140
+ "audio_channel_layout" => first_audio_stream_json&.dig("channel_layout"), # stereo or mono or (dolby) 2.1, or something else.
141
+ }.compact
142
+ end
143
+
144
+ # just the ffprobe version please. This is also available
145
+ # in ffprobe_hash
146
+ def ffprobe_version
147
+ ffprobe_hash.dig("program_version", "version")
148
+ end
149
+
150
+ private
151
+
152
+ def ffprobe_stdout
153
+ @ffprobe_output ||= TTY::Command.new(printer: :null).run(
154
+ ffprobe_command,
155
+ *ffprobe_options,
156
+ input_arg,
157
+ timeout: ffprobe_timeout).out
158
+ end
159
+
160
+ def first_video_stream_json
161
+ @first_video_stream_json ||= ffprobe_hash["streams"].find { |stream| stream["codec_type"] == "video" }
162
+ end
163
+
164
+ def first_audio_stream_json
165
+ @first_audio_stream_json ||= ffprobe_hash["streams"].find { |stream| stream["codec_type"] == "audio" }
166
+ end
167
+
168
+ # There are a few different values we could choose here. We're going to choose
169
+ # `avg_frame_rate` == total duration / number of frames,
170
+ # vs (not chosen) `r_frame_rate ` "the lowest framerate with which all timestamps can be represented accurately (it is the least common multiple of all framerates in the stream)"
171
+ #
172
+ # (note this sometimes gets us not what we expected, like it gets us 29.78 fps instead of 29.97)
173
+ #
174
+ # Then we have to change it from numerator/denomominator to float truncated to two decimal places,
175
+ # which we let ruby rational do for us.
176
+ def video_frame_rate_as_float
177
+ avg_frame_rate = first_video_stream_json&.dig("avg_frame_rate")
178
+
179
+ return nil unless avg_frame_rate
180
+
181
+ return nil if avg_frame_rate.split("/")[1] == "0" # sometimes it returns '0/0', don't know why.
182
+
183
+ Rational(avg_frame_rate).to_f.round(2)
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,106 @@
1
+ require 'tty/command'
2
+
3
+ module Kithe
4
+ # Creates a JPG screen capture using ffmpeg, by default with the `thumbnail`
5
+ # filter to choose a representative frame from the first minute or so.
6
+ #
7
+ # @example tempfile = FfmpegExtractJpg.new.call(shrine_uploaded_file)
8
+ # @example tempfile = FfmpegExtractJpg.new.call(url)
9
+ # @example tempfile = FfmpegExtractJpg.new(start_seconds: 60).call(shrine_uploaded_file)
10
+ # @example tempfile = FfmpegExtractJpg.new(start_seconds: 10, width_pixels: 420).call(shrine_uploaded_file)
11
+ class FfmpegExtractJpg
12
+ class_attribute :ffmpeg_command, default: "ffmpeg"
13
+ attr_reader :start_seconds, :frame_sample_size, :width_pixels
14
+
15
+ # @param start_seconds [Integer] seek to this point to find thumbnail. If it's
16
+ # after the end of the video, you won't get a thumb back though! [Default 0]
17
+ #
18
+ # @param frame_sample_size [Integer,false,nil] argument passed to ffmpeg thumbnail filter,
19
+ # how many frames to sample, starting at start_seconds, to choose representative
20
+ # thumbnail. If set to false, thumbnail filter won't be used. If this one
21
+ # goes past the end of the video, ffmpeg is fine with it. Set to `false` to
22
+ # disable use of ffmpeg sample feature, and just use exact frame at start_seconds.
23
+ #
24
+ # NOTE: This can consume significant RAM depending on value and video resolution.
25
+ #
26
+ # [Default false, not operative]
27
+ #
28
+ # @width_pixels [Integer] output thumb at this width. aspect ratio will be
29
+ # maintained. Warning, if it's larger than video original, ffmpeg will
30
+ # upscale! If set to nil, thumb will be output at original video
31
+ # resolution. [Default nil]
32
+ def initialize(start_seconds: 0, frame_sample_size: false, width_pixels: nil)
33
+ @start_seconds = start_seconds
34
+ @frame_sample_size = frame_sample_size
35
+ @width_pixels = width_pixels
36
+ end
37
+
38
+
39
+
40
+ # @param input_arg [String,File,Shrine::UploadedFile] local File; String that
41
+ # can be URL or local file path; or Shrine::UploadedFile. If Shrine::UploadedFile,
42
+ # we'll try to get a URL from it if we can, otherwise use or make a local tempfile.
43
+ # Most efficient is if we have a remote URL to give ffmpeg, one way or another!
44
+ #
45
+ # @returns [Tempfile] jpg extracted from movie
46
+ def call(input_arg)
47
+ if input_arg.kind_of?(Shrine::UploadedFile)
48
+ if input_arg.respond_to?(:url) && input_arg.url&.start_with?(/https?\:/)
49
+ _call(input_arg.url)
50
+ else
51
+ Shrine.with_file(input_arg) do |local_file|
52
+ _call(local_file.path)
53
+ end
54
+ end
55
+ elsif input_arg.respond_to?(:path)
56
+ _call(input_arg.path)
57
+ else
58
+ _call(input_arg.to_s)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # Internal implementation, after input has already been normalized to an
65
+ # string that can be an ffmpeg arg.
66
+ #
67
+ # @param ffmpeg_source_arg [String] filepath or URL. ffmpeg can take urls, which
68
+ # can be very efficient.
69
+ #
70
+ # @returns Tempfile pointing to a thumbnail
71
+ def _call(ffmpeg_source_arg)
72
+ tempfile = Tempfile.new(['temp_deriv', ".jpg"])
73
+
74
+ ffmpeg_args = produce_ffmpeg_args(input_arg: ffmpeg_source_arg, output_path: tempfile.path)
75
+
76
+ TTY::Command.new(printer: :null).run(*ffmpeg_args)
77
+
78
+ return tempfile
79
+ rescue StandardError => e
80
+ tempfile.unlink if tempfile
81
+ raise e
82
+ end
83
+
84
+ def produce_ffmpeg_args(input_arg:, output_path:)
85
+ ffmpeg_args = [ffmpeg_command, "-y"]
86
+
87
+ if start_seconds && start_seconds > 0
88
+ ffmpeg_args.concat ["-ss", start_seconds.to_i]
89
+ end
90
+
91
+ ffmpeg_args.concat ["-i", input_arg]
92
+
93
+ video_filter_parts = []
94
+ video_filter_parts << "thumbnail=#{frame_sample_size}" if (frame_sample_size || 0) > 1
95
+ video_filter_parts << "scale=#{width_pixels}:-1" if width_pixels
96
+
97
+ if video_filter_parts.present?
98
+ ffmpeg_args.concat ["-vf", video_filter_parts.join(',')]
99
+ end
100
+
101
+ ffmpeg_args.concat ["-frames:v", "1"]
102
+
103
+ ffmpeg_args << output_path
104
+ end
105
+ end
106
+ end
@@ -24,6 +24,9 @@ module Kithe
24
24
  # it's nil, meaning we'll find the indexer to use from current thread settings,
25
25
  # or global settings.
26
26
  #
27
+ # (note: this design means we can't currently use traject processing_thread_pool for
28
+ # concurrency, sorry.)
29
+ #
27
30
  # @param writer [Traject::Writer] Can pass i a custom Traject::Writer which the
28
31
  # index representation will be sent to. By default it's nil, meaning we'll find
29
32
  # the writer to use from current thread settings or global settings.
@@ -27,7 +27,8 @@ class Kithe::Asset < Kithe::Model
27
27
  to: :file, allow_nil: true
28
28
  delegate :stored?, to: :file_attacher
29
29
  delegate :set_promotion_directives, :promotion_directives, to: :file_attacher
30
-
30
+ # delegate "metadata" as #file_metadata
31
+ delegate :metadata, to: :file, prefix: true, allow_nil: true
31
32
 
32
33
  # will be sent to file_attacher.set_promotion_directives, provided by our
33
34
  # kithe_promotion_hooks shrine plugin.
@@ -42,48 +43,24 @@ class Kithe::Asset < Kithe::Model
42
43
  before_promotion :refresh_metadata_before_promotion
43
44
  after_promotion :schedule_derivatives
44
45
 
45
- # A convenience to call file_attacher.create_persisted_derivatives (from :kithe_derivatives)
46
- # to create derivatives with conncurrent access safety, with the :kithe_derivatives
47
- # processor argument, to create derivatives defined using kithe_derivative_definitions.
48
- #
49
- # This is designed for use with kithe_derivatives processor, and has options only relevant
50
- # to it, although could be expanded to take a processor argument in the future if needed.
51
- #
52
- # Create derivatives for every definition added to uploader/attacher with kithe_derivatives
53
- # `define_derivative`. Ordinarily will create a definition for every definition
54
- # that has not been marked `default_create: false`.
55
- #
56
- # But you can also pass `only` and/or `except` to customize the list of definitions to be created,
57
- # possibly including some that are `default_create: false`.
58
- #
59
- # create_derivatives should be idempotent. If it has failed having only created some derivatives,
60
- # you can always just run it again.
46
+ # Proxies to file_attacher.kithe_create_derivatives to create kithe managed derivatives.
61
47
  #
62
- # Will normally re-create derivatives (per existing definitions) even if they already exist,
63
- # but pass `lazy: false` to skip creating if a derivative with a given key already exists.
64
- # This will use the asset `derivatives` association, so if you are doing this in bulk for several
65
- # assets, you should eager-load the derivatives association for efficiency.
48
+ # This will include any derivatives you created with in the uploader with kithe
49
+ # Attacher.define_derivative, as well as any derivative processors you opted into
50
+ # kithe management with `kithe_include_derivatives_processors` in uploader.
66
51
  #
67
- # ## Avoiding eager-download
52
+ # This is safe for concurrent updates to the underlying model, the derivatives will
53
+ # be consistent and not left orphaned.
68
54
  #
69
- # Additionally, this has a feature to 'trick' shrine into not eager downloading
70
- # the original before our kithe_derivatives processor has a chance to decide if it
71
- # needs to create any derivatives (based on `lazy` arg), making no-op
72
- # create_derivatives so much faster and more efficient, which matters when doing
73
- # bulk operations say with our rake task.
74
- #
75
- # kithe_derivatives processor must then do a Shrine.with_file to make sure
76
- # it's a local file, when needed.
77
- #
78
- # See https://github.com/shrinerb/shrine/issues/470
55
+ # By default it will create all derivatives configured for default generation,
56
+ # but you can customize by listing derivative keys with :only and :except options,
57
+ # and with :lazy option which, if true, only executes derivative creation if
58
+ # a given derivative doesn't already exist.
79
59
  #
60
+ # See more in docs for kithe at
61
+ # Shrine::Plugins::KitheDerivativeDefinitions::AttacherMethods#kithe_create_derivatives
80
62
  def create_derivatives(only: nil, except: nil, lazy: false)
81
- source = file
82
- return false unless source
83
-
84
- local_files = file_attacher.process_derivatives(:kithe_derivatives, only: only, except: except, lazy: lazy)
85
-
86
- file_attacher.add_persisted_derivatives(local_files)
63
+ file_attacher.kithe_create_derivatives(only: only, except: except, lazy: lazy)
87
64
  end
88
65
 
89
66
 
@@ -185,7 +162,8 @@ class Kithe::Asset < Kithe::Model
185
162
 
186
163
  # called by after_promotion hook
187
164
  def schedule_derivatives
188
- return unless self.file_attacher.kithe_derivative_definitions.present? # no need to schedule if we don't have any
165
+ # no need to schedule if we don't have any
166
+ return unless self.file_attacher.kithe_derivative_definitions.present? || self.file_attacher.kithe_include_derivatives_processors.present?
189
167
 
190
168
  Kithe::TimingPromotionDirective.new(key: :create_derivatives, directives: file_attacher.promotion_directives) do |directive|
191
169
  if directive.inline?
@@ -32,8 +32,11 @@ class Kithe::Model < ActiveRecord::Base
32
32
  # this should only apply to Works, but we define it here so we can preload it
33
33
  # when fetching all Kithe::Model. And it's to Kithe::Model so it can include
34
34
  # both Works and Assets. We do some app-level validation to try and make it used
35
- # as intended.
36
- has_many :members, class_name: "Kithe::Model", foreign_key: :parent_id, inverse_of: :parent, dependent: :destroy
35
+ # as intended. Members are by default ordered by position, then created_at.
36
+ has_many :members, -> { order(position: :asc, created_at: :asc) },
37
+ class_name: "Kithe::Model", foreign_key: :parent_id,
38
+ inverse_of: :parent, dependent: :destroy
39
+
37
40
  belongs_to :parent, class_name: "Kithe::Model", inverse_of: :members, optional: true
38
41
 
39
42
  # a self-referential many-to-many is a bit confusing, but our "contains" relation
@@ -130,11 +133,15 @@ class Kithe::Model < ActiveRecord::Base
130
133
  LIMIT 1;
131
134
  EOS
132
135
 
133
- # trying to use a prepared statement, hoping it means performance advantage
136
+ # trying to use a prepared statement, hoping it means performance advantage,
137
+ # this is super undocumented
138
+
139
+ bind = ActiveRecord::Relation::QueryAttribute.new("m.id", self.representative_id, ActiveRecord::Type::Value.new)
140
+
134
141
  result = self.class.connection.select_all(
135
142
  recursive_cte,
136
143
  "set_leaf_representative",
137
- [[nil, self.representative_id]],
144
+ [bind],
138
145
  preparable: true
139
146
  ).first.try(:dig, "id")
140
147
 
@@ -63,11 +63,24 @@
63
63
  class Kithe::Parameters < ActionController::Parameters
64
64
  attr_reader :auto_allowed_keys
65
65
 
66
- def initialize(hash = {})
67
- if hash.respond_to?(:to_unsafe_h)
68
- hash = hash.to_unsafe_h
66
+
67
+ # Rails 7 adds another initializer method, annoyingly
68
+ if Rails.version.split.first.to_i >= 7
69
+ def initialize(hash = {}, logging_context = {})
70
+ if hash.respond_to?(:to_unsafe_h)
71
+ hash = hash.to_unsafe_h
72
+ end
73
+
74
+ super(hash, logging_context)
75
+ end
76
+ else
77
+ def initialize(hash = {})
78
+ if hash.respond_to?(:to_unsafe_h)
79
+ hash = hash.to_unsafe_h
80
+ end
81
+
82
+ super(hash)
69
83
  end
70
- super(hash)
71
84
  end
72
85
 
73
86
  def permit_attr_json(klass, except:nil)
@@ -6,22 +6,21 @@ module Kithe
6
6
  def initialize(solr_url:, writer_class_name:, writer_settings:,
7
7
  model_name_solr_field:, solr_id_value_attribute:, disable_callbacks: false,
8
8
  batching_mode_batch_size: 100)
9
- @solr_url = solr_url
10
9
  @writer_class_name = writer_class_name
11
10
  @writer_settings = writer_settings
12
11
  @model_name_solr_field = model_name_solr_field
13
12
  @solr_id_value_attribute = solr_id_value_attribute || 'id'
14
13
  @batching_mode_batch_size = batching_mode_batch_size
14
+
15
+ # use our local setter to set solr_url also in writer_settings
16
+ solr_url = solr_url
15
17
  end
16
18
 
17
- # Use configured solr_url, and merge together with configured
18
- # writer_settings
19
- def writer_settings
20
- if solr_url
21
- { "solr.url" => solr_url }.merge(@writer_settings)
22
- else
23
- @writer_settings
24
- end
19
+
20
+ # set solr_url also in writer_settings, cause it's expected there.
21
+ def solr_url=(v)
22
+ @solr_url = v
23
+ writer_settings["solr.url"] = v if writer_settings
25
24
  end
26
25
 
27
26
  # Turn writer_class_name into an actual Class object.
data/lib/kithe/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Kithe
2
- VERSION = '2.3.0'
2
+ VERSION = '2.6.0'
3
3
  end
@@ -5,9 +5,25 @@ class Shrine
5
5
  # use Rails class_attribute to conveniently have a class-level place
6
6
  # to store our derivative definitions that are inheritable and overrideable.
7
7
  # We store it on the Attacher class, because that's where shrine
8
- # puts derivative processor definitions, so seems appropriate.
8
+ # puts derivative processor definitions, so seems appropriate. Normally
9
+ # not touched directly by non-kithe code.
9
10
  uploader::Attacher.class_attribute :kithe_derivative_definitions, instance_writer: false, default: []
10
11
 
12
+ # Kithe exersizes lifecycle control over derivatives, normally just the
13
+ # shrine processor labelled :kithe_derivatives. But you can opt additional shrine
14
+ # derivative processors into kithe control by listing their labels in this attribute.
15
+ #
16
+ # @example
17
+ #
18
+ # class AssetUploader < Kithe::AssetUploader
19
+ # Attacher.kithe_include_derivatives_processors += [:my_processor]
20
+ # Attacher.derivatives(:my_processor) do |original|
21
+ # derivatives
22
+ # end
23
+ # end
24
+ #
25
+ uploader::Attacher.class_attribute :kithe_include_derivatives_processors, instance_writer: false, default: []
26
+
11
27
  # Register our derivative processor, that will create our registered derivatives,
12
28
  # with our custom options.
13
29
  #
@@ -31,11 +47,12 @@ class Shrine
31
47
  #
32
48
  # The most basic definition consists of a derivative key, and a ruby block that
33
49
  # takes the original file, transforms it, and returns a ruby File or other
34
- # (shrine-compatible) IO-like object. It will usually be done inside a custom Asset
35
- # class definition.
50
+ # (shrine-compatible) IO-like object. It will usually be done inside your custom
51
+ # AssetUploader class definition.
36
52
  #
37
- # class Asset < Kithe::Asset
38
- # define_derivative :thumbnail do |original_file|
53
+ # class AssetUploader < Kithe::AssetUploader
54
+ # Attacher.define_derivative :thumbnail do |original_file|
55
+ # someTempFileOrOtherIo
39
56
  # end
40
57
  # end
41
58
  #
@@ -52,7 +69,7 @@ class Shrine
52
69
  # will be passed in. You can then get the model object from `attacher.record`, or the
53
70
  # original file as a `Shrine::UploadedFile` object with `attacher.file`.
54
71
  #
55
- # define_derivative :thumbnail do |original_file, attacher:|
72
+ # Attacher.define_derivative :thumbnail do |original_file, attacher:|
56
73
  # attacher.record.title, attacher.file.width, attacher.file.content_type # etc
57
74
  # end
58
75
  #
@@ -62,7 +79,7 @@ class Shrine
62
79
  # remain, and be accessible, where they were created; there is no built-in solution at present
63
80
  # for moving them).
64
81
  #
65
- # define_derivative :thumbnail, storage_key: :my_thumb_storage do |original| # ...
82
+ # Attacher.define_derivative :thumbnail, storage_key: :my_thumb_storage do |original| # ...
66
83
  #
67
84
  # You can also set `default_create: false` if you want a particular definition not to be
68
85
  # included in a no-arg `asset.create_derivatives` that is normally triggered on asset creation.
@@ -101,6 +118,86 @@ class Shrine
101
118
  end.freeze
102
119
  end
103
120
  end
121
+
122
+ module AttacherMethods
123
+
124
+
125
+ # Similar to shrine create_derivatives, but with kithe standards:
126
+ #
127
+ # * Will call the :kithe_derivatives processor (that handles any define_derivative definitions),
128
+ # plus any processors you've configured with kithe_include_derivatives_processors
129
+ #
130
+ # * Uses the methods added by :kithe_persisted_derivatives to add derivatives completely
131
+ # concurrency-safely, if the model had it's attachment changed concurrently, you
132
+ # won't get derivatives attached that belong to old version of original attachment,
133
+ # and won't get any leftover "orphaned" derivatives either.
134
+ #
135
+ # The :kithe_derivatives processor has additional logic and options for determining
136
+ # *which* derivative definitions -- created with `define_deriative` will be executed:
137
+ #
138
+ # * Ordinarily will create a definition for every definition that has not been marked
139
+ # `default_create: false`.
140
+ #
141
+ # * But you can also pass `only` and/or `except` to customize the list of definitions to be created,
142
+ # possibly including some that are `default_create: false`.
143
+ #
144
+ # * Will normally re-create derivatives (per existing definitions) even if they already exist,
145
+ # but pass `lazy: false` to skip creating if a derivative with a given key already exists.
146
+ # This will use the asset `derivatives` association, so if you are doing this in bulk for several
147
+ # assets, you should eager-load the derivatives association for efficiency.
148
+ #
149
+ # If you've added any custom processors with `kithe_include_derivatives_processors`, it's
150
+ # your responsibility to make them respect those options. See #process_kithe_derivative?
151
+ # helper method.
152
+ #
153
+ # create_derivatives should be idempotent. If it has failed having only created some derivatives,
154
+ # you can always just run it again.
155
+ #
156
+ def kithe_create_derivatives(only: nil, except: nil, lazy: false)
157
+ return false unless file
158
+
159
+ local_files = self.process_derivatives(:kithe_derivatives, only: only, except: except, lazy: lazy)
160
+
161
+ # include any other configured processors
162
+ self.kithe_include_derivatives_processors.each do |processor|
163
+ local_files.merge!(
164
+ self.process_derivatives(processor.to_sym, only: only, except: except, lazy: lazy)
165
+ )
166
+ end
167
+
168
+ self.add_persisted_derivatives(local_files)
169
+ end
170
+
171
+ # a helper method that you can use in your own shrine processors to
172
+ # handle only/except/lazy guarding logic.
173
+ #
174
+ # @return [Boolean] should the `key` be processed based on only/except/lazy conditions?
175
+ #
176
+ # @param key [Symbol] derivative key to check for guarded processing
177
+ # @param only [Array<Symbol>] If present, method will only return true if `key` is included in `only`
178
+ # @param except [Array<Symbol] If present, method will only return true if `key` is NOT included in `except`
179
+ # @param lazy [Boolean] If true, method will only return true if derivative key is not already present
180
+ # in attacher with a value.
181
+ #
182
+ def process_kithe_derivative?(key, **options)
183
+ key = key.to_sym
184
+ only = options[:only] && Array(options[:only]).map(&:to_sym)
185
+ except = options[:except] && Array(options[:except]).map(&:to_sym)
186
+ lazy = !! options[:lazy]
187
+
188
+ (only.nil? ? true : only.include?(key)) &&
189
+ (except.nil? || ! except.include?(key)) &&
190
+ (!lazy || !derivatives.keys.include?(key))
191
+ end
192
+
193
+ # Convenience to check #process_kithe_derivative? for multiple keys at once,
194
+ # @return true if any key returns true
195
+ #
196
+ # @example process_any_kithe_derivative?([:thumb_mini, :thumb_large], only: [:thumb_large, :thumb_mega], lazy: true)
197
+ def process_any_kithe_derivative?(keys, **options)
198
+ keys.any? { |k| process_kithe_derivative?(k, **options) }
199
+ end
200
+ end
104
201
  end
105
202
  register_plugin(:kithe_derivative_definitions, KitheDerivativeDefinitions)
106
203
  end
@@ -134,6 +134,10 @@ class Shrine
134
134
  def remove_persisted_derivatives(*paths, **options)
135
135
  return if paths.empty?
136
136
 
137
+ # Shrine does weird things if we pass in Strings, let's save ourselves
138
+ # the terrible debugging on that mistake, and noramlize to symbols
139
+ paths = paths.collect(&:to_sym)
140
+
137
141
  other_changes_allowed = !!options.delete(:allow_other_changes)
138
142
  if record && !other_changes_allowed && record.changed?
139
143
  raise TypeError.new("Can't safely add_persisted_derivatives on model with unsaved changes. Pass `allow_other_changes: true` to force.")
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kithe
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Rochkind
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-12-02 00:00:00.000000000 Z
11
+ date: 2022-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -19,7 +19,7 @@ dependencies:
19
19
  version: 5.2.1
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
- version: '6.2'
22
+ version: '7.1'
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -29,7 +29,7 @@ dependencies:
29
29
  version: 5.2.1
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
- version: '6.2'
32
+ version: '7.1'
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: attr_json
35
35
  requirement: !ruby/object:Gem::Requirement
@@ -345,6 +345,8 @@ files:
345
345
  - README.md
346
346
  - Rakefile
347
347
  - app/assets/config/kithe_manifest.js
348
+ - app/characterization/kithe/ffprobe_characterization.rb
349
+ - app/derivative_transformers/kithe/ffmpeg_extract_jpg.rb
348
350
  - app/derivative_transformers/kithe/ffmpeg_transformer.rb
349
351
  - app/derivative_transformers/kithe/vips_cli_image_to_jpeg.rb
350
352
  - app/helpers/kithe/form_helper.rb
@@ -424,7 +426,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
424
426
  - !ruby/object:Gem::Version
425
427
  version: '0'
426
428
  requirements: []
427
- rubygems_version: 3.1.6
429
+ rubygems_version: 3.2.33
428
430
  signing_key:
429
431
  specification_version: 4
430
432
  summary: Shareable tools/components for building a digital collections app in Rails.