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.
- checksums.yaml +7 -0
- data/README.md +336 -0
- data/Rakefile +12 -0
- data/lib/ffmpeg/active_storage/analyzer.rb +79 -0
- data/lib/ffmpeg/active_storage/previewer.rb +67 -0
- data/lib/ffmpeg/command.rb +218 -0
- data/lib/ffmpeg/configuration.rb +113 -0
- data/lib/ffmpeg/errors.rb +81 -0
- data/lib/ffmpeg/keyframe_extractor.rb +213 -0
- data/lib/ffmpeg/media.rb +385 -0
- data/lib/ffmpeg/railtie.rb +31 -0
- data/lib/ffmpeg/scene_detector.rb +170 -0
- data/lib/ffmpeg/stream.rb +229 -0
- data/lib/ffmpeg/tasks/ffmpeg.rake +108 -0
- data/lib/ffmpeg/transcoder.rb +139 -0
- data/lib/ffmpeg/version.rb +11 -0
- data/lib/ffmpeg.rb +96 -0
- data/ruby-ffmpeg.gemspec +49 -0
- metadata +123 -0
data/lib/ffmpeg/media.rb
ADDED
|
@@ -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
|