ruby-ffmpeg 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.
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FFMPEG
6
+ # Represents a media file and provides access to its metadata
7
+ #
8
+ # @example Basic usage
9
+ # media = FFMPEG::Media.new("/path/to/video.mp4")
10
+ # media.duration # => 120.5
11
+ # media.resolution # => "1920x1080"
12
+ # media.video_codec # => "h264"
13
+ # media.audio_codec # => "aac"
14
+ #
15
+ # @example Checking properties
16
+ # media.valid? # => true
17
+ # media.video? # => true
18
+ # media.audio? # => true
19
+ # media.hd? # => true
20
+ #
21
+ # @example Transcoding
22
+ # media.transcode("/path/to/output.webm", video_codec: "libvpx-vp9")
23
+ #
24
+ class Media
25
+ # @return [String] path to the media file
26
+ attr_reader :path
27
+
28
+ # @return [Hash] raw ffprobe output
29
+ attr_reader :raw
30
+
31
+ # @return [Array<Stream>] all streams in the media
32
+ attr_reader :streams
33
+
34
+ # @return [Hash] format information
35
+ attr_reader :format
36
+
37
+ # Create a new Media instance
38
+ # @param path [String] path to the media file
39
+ # @raise [MediaNotFound] if file doesn't exist
40
+ # @raise [InvalidMedia] if file can't be parsed
41
+ def initialize(path)
42
+ @path = File.expand_path(path)
43
+ validate_file!
44
+ probe!
45
+ end
46
+
47
+ # @return [Boolean] true if media was successfully parsed
48
+ def valid?
49
+ @valid
50
+ end
51
+
52
+ # @return [Float, nil] duration in seconds
53
+ def duration
54
+ format["duration"]&.to_f
55
+ end
56
+
57
+ # @return [Integer, nil] bit rate in bits/sec
58
+ def bit_rate
59
+ format["bit_rate"]&.to_i
60
+ end
61
+
62
+ # @return [Integer, nil] file size in bytes
63
+ def size
64
+ format["size"]&.to_i || File.size(path)
65
+ end
66
+
67
+ # @return [String, nil] container format name
68
+ def format_name
69
+ format["format_name"]
70
+ end
71
+
72
+ # @return [String, nil] format long name
73
+ def format_long_name
74
+ format["format_long_name"]
75
+ end
76
+
77
+ # @return [Hash] format tags (title, artist, etc.)
78
+ def tags
79
+ format["tags"] || {}
80
+ end
81
+
82
+ # @return [String, nil] media title from tags
83
+ def title
84
+ tags["title"]
85
+ end
86
+
87
+ # @return [Time, nil] creation time if available
88
+ def creation_time
89
+ time_str = tags["creation_time"]
90
+ Time.parse(time_str) if time_str
91
+ rescue ArgumentError
92
+ nil
93
+ end
94
+
95
+ # === Stream Accessors ===
96
+
97
+ # @return [Array<Stream>] all video streams
98
+ def video_streams
99
+ @video_streams ||= streams.select(&:video?)
100
+ end
101
+
102
+ # @return [Array<Stream>] all audio streams
103
+ def audio_streams
104
+ @audio_streams ||= streams.select(&:audio?)
105
+ end
106
+
107
+ # @return [Array<Stream>] all subtitle streams
108
+ def subtitle_streams
109
+ @subtitle_streams ||= streams.select(&:subtitle?)
110
+ end
111
+
112
+ # @return [Stream, nil] primary video stream
113
+ def video
114
+ video_streams.find(&:default) || video_streams.first
115
+ end
116
+
117
+ # @return [Stream, nil] primary audio stream
118
+ def audio
119
+ audio_streams.find(&:default) || audio_streams.first
120
+ end
121
+
122
+ # @return [Boolean] true if media has video
123
+ def video?
124
+ video_streams.any?
125
+ end
126
+
127
+ # @return [Boolean] true if media has audio
128
+ def audio?
129
+ audio_streams.any?
130
+ end
131
+
132
+ # === Video Properties (shortcuts to primary video stream) ===
133
+
134
+ # @return [Integer, nil] video width
135
+ def width
136
+ video&.width
137
+ end
138
+
139
+ # @return [Integer, nil] video height
140
+ def height
141
+ video&.height
142
+ end
143
+
144
+ # @return [String, nil] resolution as "WIDTHxHEIGHT"
145
+ def resolution
146
+ video&.resolution
147
+ end
148
+
149
+ # @return [String, nil] video codec name
150
+ def video_codec
151
+ video&.codec
152
+ end
153
+
154
+ # @return [Float, nil] video frame rate
155
+ def frame_rate
156
+ video&.frame_rate
157
+ end
158
+
159
+ # @return [Integer, nil] video bit rate
160
+ def video_bit_rate
161
+ video&.bit_rate
162
+ end
163
+
164
+ # @return [Integer, nil] video rotation in degrees
165
+ def rotation
166
+ video&.rotation
167
+ end
168
+
169
+ # @return [String, nil] pixel format
170
+ def pixel_format
171
+ video&.pixel_format
172
+ end
173
+
174
+ # @return [Boolean] true if video is portrait
175
+ def portrait?
176
+ video&.portrait? || false
177
+ end
178
+
179
+ # @return [Boolean] true if video is landscape
180
+ def landscape?
181
+ video&.landscape? || false
182
+ end
183
+
184
+ # @return [Boolean] true if video is HD (720p+)
185
+ def hd?
186
+ video&.hd? || false
187
+ end
188
+
189
+ # @return [Boolean] true if video is 4K (2160p+)
190
+ def uhd?
191
+ video&.uhd? || false
192
+ end
193
+
194
+ # === Audio Properties (shortcuts to primary audio stream) ===
195
+
196
+ # @return [String, nil] audio codec name
197
+ def audio_codec
198
+ audio&.codec
199
+ end
200
+
201
+ # @return [Integer, nil] audio sample rate
202
+ def sample_rate
203
+ audio&.sample_rate
204
+ end
205
+
206
+ # @return [Integer, nil] number of audio channels
207
+ def audio_channels
208
+ audio&.channels
209
+ end
210
+
211
+ # @return [String, nil] audio channel layout
212
+ def channel_layout
213
+ audio&.channel_layout
214
+ end
215
+
216
+ # @return [Integer, nil] audio bit rate
217
+ def audio_bit_rate
218
+ audio&.bit_rate
219
+ end
220
+
221
+ # === Frame Information ===
222
+
223
+ # @return [Integer, nil] approximate total frame count
224
+ def frame_count
225
+ return nil unless duration && frame_rate
226
+
227
+ (duration * frame_rate).round
228
+ end
229
+
230
+ # Calculate timestamp for a specific frame
231
+ # @param frame_number [Integer] frame number (0-indexed)
232
+ # @return [Float, nil] timestamp in seconds
233
+ def frame_to_timestamp(frame_number)
234
+ return nil unless frame_rate && frame_rate.positive?
235
+
236
+ frame_number.to_f / frame_rate
237
+ end
238
+
239
+ # Calculate frame number for a specific timestamp
240
+ # @param timestamp [Float] timestamp in seconds
241
+ # @return [Integer, nil] frame number (0-indexed)
242
+ def timestamp_to_frame(timestamp)
243
+ return nil unless frame_rate
244
+
245
+ (timestamp.to_f * frame_rate).round
246
+ end
247
+
248
+ # === Transcoding ===
249
+
250
+ # Transcode media to a new file
251
+ # @param output_path [String] path to output file
252
+ # @param options [Hash] transcoding options
253
+ # @yield [Float] progress percentage (0-100)
254
+ # @return [Media] the transcoded media
255
+ # @raise [TranscodingError] if transcoding fails
256
+ def transcode(output_path, **options, &block)
257
+ transcoder = Transcoder.new(self, output_path, **options)
258
+ transcoder.run(&block)
259
+ end
260
+
261
+ # === Scene Detection ===
262
+
263
+ # Detect scene changes in the video
264
+ # @param threshold [Float] scene detection threshold (0.0-1.0)
265
+ # @return [Array<Hash>] array of scene change points
266
+ def detect_scenes(threshold: 0.3)
267
+ detector = SceneDetector.new(self)
268
+ detector.detect(threshold: threshold)
269
+ end
270
+
271
+ # === Keyframe Extraction ===
272
+
273
+ # Extract keyframes from the video
274
+ # @param output_dir [String] directory for extracted frames
275
+ # @param interval [Float, nil] interval between frames in seconds
276
+ # @param count [Integer, nil] number of frames to extract
277
+ # @param timestamps [Array<Float>, nil] specific timestamps
278
+ # @return [Array<String>] paths to extracted frames
279
+ def extract_keyframes(output_dir:, interval: nil, count: nil, timestamps: nil)
280
+ extractor = KeyframeExtractor.new(self)
281
+
282
+ if timestamps
283
+ extractor.extract_at_timestamps(timestamps, output_dir: output_dir)
284
+ elsif count
285
+ extractor.extract_count(count, output_dir: output_dir)
286
+ else
287
+ extractor.extract_at_intervals(interval: interval || 5.0, output_dir: output_dir)
288
+ end
289
+ end
290
+
291
+ # === Screenshots ===
292
+
293
+ # Take a screenshot at a specific timestamp
294
+ # @param timestamp [Float] timestamp in seconds
295
+ # @param output_path [String] path for output image
296
+ # @param resolution [String, nil] output resolution (e.g., "640x480")
297
+ # @return [String] path to the screenshot
298
+ def screenshot(timestamp, output_path, resolution: nil)
299
+ extractor = KeyframeExtractor.new(self)
300
+ extractor.extract_at_timestamps([timestamp], output_dir: File.dirname(output_path)).first
301
+ end
302
+
303
+ # @return [String] human-readable description
304
+ def to_s
305
+ parts = ["Media: #{File.basename(path)}"]
306
+ parts << "Duration: #{format_duration(duration)}" if duration
307
+ parts << "Resolution: #{resolution}" if resolution
308
+ parts << "Codecs: #{video_codec}/#{audio_codec}" if video_codec || audio_codec
309
+ parts.join(" | ")
310
+ end
311
+
312
+ # @return [Hash] serializable representation
313
+ def to_h
314
+ {
315
+ path: path,
316
+ duration: duration,
317
+ size: size,
318
+ format: format_name,
319
+ video: video ? {
320
+ codec: video_codec,
321
+ resolution: resolution,
322
+ frame_rate: frame_rate,
323
+ bit_rate: video_bit_rate
324
+ } : nil,
325
+ audio: audio ? {
326
+ codec: audio_codec,
327
+ sample_rate: sample_rate,
328
+ channels: audio_channels,
329
+ bit_rate: audio_bit_rate
330
+ } : nil,
331
+ streams: streams.map(&:to_s)
332
+ }
333
+ end
334
+
335
+ private
336
+
337
+ def validate_file!
338
+ raise MediaNotFound, path unless File.exist?(path)
339
+ raise MediaNotFound, path unless File.readable?(path)
340
+ end
341
+
342
+ def probe!
343
+ result = Command.run(
344
+ FFMPEG.ffprobe_binary,
345
+ "-v", "quiet",
346
+ "-print_format", "json",
347
+ "-show_format",
348
+ "-show_streams",
349
+ path
350
+ )
351
+
352
+ unless result.success?
353
+ @valid = false
354
+ raise InvalidMedia.new(path, "ffprobe failed: #{result.error}")
355
+ end
356
+
357
+ begin
358
+ @raw = JSON.parse(result.output)
359
+ rescue JSON::ParserError => e
360
+ @valid = false
361
+ raise InvalidMedia.new(path, "Invalid ffprobe output: #{e.message}")
362
+ end
363
+
364
+ @format = @raw["format"] || {}
365
+ @streams = (@raw["streams"] || []).map { |s| Stream.new(s) }
366
+ @valid = @streams.any?
367
+
368
+ raise InvalidMedia.new(path, "No streams found") unless @valid
369
+ end
370
+
371
+ def format_duration(seconds)
372
+ return "0:00" unless seconds
373
+
374
+ hours = (seconds / 3600).floor
375
+ minutes = ((seconds % 3600) / 60).floor
376
+ secs = (seconds % 60).floor
377
+
378
+ if hours.positive?
379
+ format("%d:%02d:%02d", hours, minutes, secs)
380
+ else
381
+ format("%d:%02d", minutes, secs)
382
+ end
383
+ end
384
+ end
385
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module FFMPEG
6
+ # Rails integration for FFMPEG
7
+ #
8
+ # Automatically configures FFMPEG with Rails defaults when the gem is loaded
9
+ # in a Rails application.
10
+ #
11
+ # @example Configuration in initializer
12
+ # # config/initializers/ffmpeg.rb
13
+ # FFMPEG.configure do |config|
14
+ # config.logger = Rails.logger
15
+ # config.temp_dir = Rails.root.join("tmp/ffmpeg")
16
+ # end
17
+ #
18
+ class Railtie < Rails::Railtie
19
+ initializer "ffmpeg.configure" do
20
+ FFMPEG.configure do |config|
21
+ config.logger = Rails.logger if Rails.logger
22
+ config.temp_dir = Rails.root.join("tmp/ffmpeg").to_s
23
+ end
24
+ end
25
+
26
+ # Add rake tasks if available
27
+ rake_tasks do
28
+ load File.expand_path("tasks/ffmpeg.rake", __dir__)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FFMPEG
4
+ # Detects scene changes in video using FFmpeg's scene detection filter
5
+ #
6
+ # @example Basic usage
7
+ # detector = SceneDetector.new(media)
8
+ # scenes = detector.detect(threshold: 0.3)
9
+ # # => [
10
+ # # { timestamp: 0.0, score: 1.0 },
11
+ # # { timestamp: 5.23, score: 0.45 },
12
+ # # { timestamp: 12.8, score: 0.38 }
13
+ # # ]
14
+ #
15
+ # @example With custom options
16
+ # scenes = detector.detect(
17
+ # threshold: 0.4,
18
+ # min_scene_length: 2.0,
19
+ # max_scenes: 20
20
+ # )
21
+ #
22
+ class SceneDetector
23
+ # @return [Media] source media
24
+ attr_reader :media
25
+
26
+ # Create a new SceneDetector
27
+ # @param media [Media] source media
28
+ def initialize(media)
29
+ @media = media
30
+ validate_media!
31
+ end
32
+
33
+ # Detect scene changes
34
+ # @param threshold [Float] scene detection threshold (0.0-1.0)
35
+ # Lower values detect more scene changes
36
+ # @param min_scene_length [Float, nil] minimum time between scenes in seconds
37
+ # @param max_scenes [Integer, nil] maximum number of scenes to detect
38
+ # @return [Array<Hash>] detected scenes with :timestamp and :score
39
+ # @raise [SceneDetectionError] if detection fails
40
+ def detect(threshold: 0.3, min_scene_length: nil, max_scenes: nil)
41
+ scenes = run_detection(threshold)
42
+
43
+ # Always include first frame
44
+ scenes.unshift({ timestamp: 0.0, score: 1.0 }) unless scenes.any? { |s| s[:timestamp].zero? }
45
+
46
+ # Filter by minimum scene length
47
+ if min_scene_length
48
+ scenes = filter_by_min_length(scenes, min_scene_length)
49
+ end
50
+
51
+ # Limit number of scenes
52
+ if max_scenes && scenes.length > max_scenes
53
+ scenes = select_best_scenes(scenes, max_scenes)
54
+ end
55
+
56
+ scenes.sort_by { |s| s[:timestamp] }
57
+ end
58
+
59
+ # Detect scenes and extract a keyframe for each
60
+ # @param threshold [Float] scene detection threshold
61
+ # @param output_dir [String] directory for extracted frames
62
+ # @param format [String] image format (jpg, png)
63
+ # @return [Array<Hash>] scenes with :timestamp, :score, and :keyframe_path
64
+ def detect_with_keyframes(threshold: 0.3, output_dir:, format: "jpg")
65
+ scenes = detect(threshold: threshold)
66
+
67
+ FileUtils.mkdir_p(output_dir)
68
+
69
+ scenes.each_with_index do |scene, index|
70
+ output_path = File.join(output_dir, "scene_%04d.#{format}" % index)
71
+ extract_frame(scene[:timestamp], output_path)
72
+ scene[:keyframe_path] = output_path
73
+ end
74
+
75
+ scenes
76
+ end
77
+
78
+ private
79
+
80
+ def validate_media!
81
+ raise SceneDetectionError, "Media has no video stream" unless media.video?
82
+ end
83
+
84
+ def run_detection(threshold)
85
+ # Use FFmpeg's select filter with scene detection
86
+ # Output format: frame:N pts:N pts_time:N.NNN scene:N.NNNN
87
+ cmd = [
88
+ FFMPEG.ffmpeg_binary,
89
+ "-i", media.path,
90
+ "-vf", "select='gt(scene,#{threshold})',showinfo",
91
+ "-f", "null",
92
+ "-"
93
+ ]
94
+
95
+ result = Command.run(*cmd)
96
+
97
+ # Even if command returns non-zero, we may have valid output
98
+ parse_scene_output(result.error)
99
+ end
100
+
101
+ def parse_scene_output(output)
102
+ scenes = []
103
+
104
+ # Parse showinfo filter output
105
+ # Example: [showinfo @ 0x...] n: 0 pts: 0 pts_time:0 ...
106
+ output.each_line do |line|
107
+ next unless line.include?("pts_time:")
108
+
109
+ match = line.match(/pts_time:(\d+\.?\d*)/)
110
+ next unless match
111
+
112
+ timestamp = match[1].to_f
113
+
114
+ # Try to extract scene score if available
115
+ score_match = line.match(/scene:(\d+\.?\d*)/)
116
+ score = score_match ? score_match[1].to_f : nil
117
+
118
+ scenes << {
119
+ timestamp: timestamp,
120
+ score: score || threshold_to_score(0.3)
121
+ }
122
+ end
123
+
124
+ scenes
125
+ end
126
+
127
+ def threshold_to_score(threshold)
128
+ # Convert threshold to approximate score
129
+ (1.0 - threshold + 0.1).clamp(0.0, 1.0)
130
+ end
131
+
132
+ def filter_by_min_length(scenes, min_length)
133
+ return scenes if scenes.empty?
134
+
135
+ filtered = [scenes.first]
136
+
137
+ scenes[1..].each do |scene|
138
+ if scene[:timestamp] - filtered.last[:timestamp] >= min_length
139
+ filtered << scene
140
+ end
141
+ end
142
+
143
+ filtered
144
+ end
145
+
146
+ def select_best_scenes(scenes, max_count)
147
+ # Keep first scene always, then select by highest score
148
+ return scenes if scenes.length <= max_count
149
+
150
+ first = scenes.first
151
+ rest = scenes[1..].sort_by { |s| -s[:score] }.first(max_count - 1)
152
+
153
+ [first, *rest].sort_by { |s| s[:timestamp] }
154
+ end
155
+
156
+ def extract_frame(timestamp, output_path)
157
+ cmd = [
158
+ FFMPEG.ffmpeg_binary,
159
+ "-ss", timestamp.to_s,
160
+ "-i", media.path,
161
+ "-vframes", "1",
162
+ "-y",
163
+ output_path
164
+ ]
165
+
166
+ result = Command.run(*cmd)
167
+ raise SceneDetectionError, "Failed to extract frame: #{result.error}" unless result.success?
168
+ end
169
+ end
170
+ end