kithe 2.3.0 → 2.6.0

Sign up to get free protection for your applications and to get access to all the features.
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.