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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FFMPEG
|
|
4
|
+
# Represents a media stream (video, audio, subtitle, etc.)
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# stream = media.video_streams.first
|
|
8
|
+
# stream.codec # => "h264"
|
|
9
|
+
# stream.width # => 1920
|
|
10
|
+
# stream.height # => 1080
|
|
11
|
+
# stream.frame_rate # => 29.97
|
|
12
|
+
# stream.video? # => true
|
|
13
|
+
#
|
|
14
|
+
class Stream
|
|
15
|
+
# Stream types
|
|
16
|
+
VIDEO = "video"
|
|
17
|
+
AUDIO = "audio"
|
|
18
|
+
SUBTITLE = "subtitle"
|
|
19
|
+
DATA = "data"
|
|
20
|
+
ATTACHMENT = "attachment"
|
|
21
|
+
|
|
22
|
+
# @return [Integer] stream index
|
|
23
|
+
attr_reader :index
|
|
24
|
+
|
|
25
|
+
# @return [String] stream type (video, audio, subtitle, data)
|
|
26
|
+
attr_reader :type
|
|
27
|
+
|
|
28
|
+
# @return [String] codec name (h264, aac, etc.)
|
|
29
|
+
attr_reader :codec
|
|
30
|
+
|
|
31
|
+
# @return [String, nil] codec long name
|
|
32
|
+
attr_reader :codec_long_name
|
|
33
|
+
|
|
34
|
+
# @return [String, nil] profile (High, Main, etc.)
|
|
35
|
+
attr_reader :profile
|
|
36
|
+
|
|
37
|
+
# @return [Integer, nil] width (video only)
|
|
38
|
+
attr_reader :width
|
|
39
|
+
|
|
40
|
+
# @return [Integer, nil] height (video only)
|
|
41
|
+
attr_reader :height
|
|
42
|
+
|
|
43
|
+
# @return [Float, nil] frame rate (video only)
|
|
44
|
+
attr_reader :frame_rate
|
|
45
|
+
|
|
46
|
+
# @return [Float, nil] average frame rate (video only)
|
|
47
|
+
attr_reader :avg_frame_rate
|
|
48
|
+
|
|
49
|
+
# @return [Integer, nil] bit rate in bits/sec
|
|
50
|
+
attr_reader :bit_rate
|
|
51
|
+
|
|
52
|
+
# @return [Float, nil] duration in seconds
|
|
53
|
+
attr_reader :duration
|
|
54
|
+
|
|
55
|
+
# @return [Integer, nil] sample rate (audio only)
|
|
56
|
+
attr_reader :sample_rate
|
|
57
|
+
|
|
58
|
+
# @return [Integer, nil] number of audio channels
|
|
59
|
+
attr_reader :channels
|
|
60
|
+
|
|
61
|
+
# @return [String, nil] channel layout (stereo, 5.1, etc.)
|
|
62
|
+
attr_reader :channel_layout
|
|
63
|
+
|
|
64
|
+
# @return [String, nil] pixel format (yuv420p, etc.)
|
|
65
|
+
attr_reader :pixel_format
|
|
66
|
+
|
|
67
|
+
# @return [Integer, nil] rotation in degrees
|
|
68
|
+
attr_reader :rotation
|
|
69
|
+
|
|
70
|
+
# @return [String, nil] language code (eng, spa, etc.)
|
|
71
|
+
attr_reader :language
|
|
72
|
+
|
|
73
|
+
# @return [Boolean] whether this is the default stream
|
|
74
|
+
attr_reader :default
|
|
75
|
+
|
|
76
|
+
# @return [Hash] raw stream data from ffprobe
|
|
77
|
+
attr_reader :raw
|
|
78
|
+
|
|
79
|
+
# Create a new Stream from ffprobe data
|
|
80
|
+
# @param data [Hash] stream data from ffprobe JSON
|
|
81
|
+
def initialize(data)
|
|
82
|
+
@raw = data
|
|
83
|
+
@index = data["index"]
|
|
84
|
+
@type = data["codec_type"]
|
|
85
|
+
@codec = data["codec_name"]
|
|
86
|
+
@codec_long_name = data["codec_long_name"]
|
|
87
|
+
@profile = data["profile"]
|
|
88
|
+
@bit_rate = data["bit_rate"]&.to_i
|
|
89
|
+
@duration = data["duration"]&.to_f
|
|
90
|
+
|
|
91
|
+
parse_video_attributes(data) if video?
|
|
92
|
+
parse_audio_attributes(data) if audio?
|
|
93
|
+
parse_tags(data["tags"] || {})
|
|
94
|
+
parse_disposition(data["disposition"] || {})
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Boolean] true if this is a video stream
|
|
98
|
+
def video?
|
|
99
|
+
type == VIDEO
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Boolean] true if this is an audio stream
|
|
103
|
+
def audio?
|
|
104
|
+
type == AUDIO
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Boolean] true if this is a subtitle stream
|
|
108
|
+
def subtitle?
|
|
109
|
+
type == SUBTITLE
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Boolean] true if this is a data stream
|
|
113
|
+
def data?
|
|
114
|
+
type == DATA
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [String, nil] resolution as "WIDTHxHEIGHT"
|
|
118
|
+
def resolution
|
|
119
|
+
return nil unless video? && width && height
|
|
120
|
+
|
|
121
|
+
"#{width}x#{height}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Float, nil] aspect ratio
|
|
125
|
+
def aspect_ratio
|
|
126
|
+
return nil unless video? && width && height && height.positive?
|
|
127
|
+
|
|
128
|
+
width.to_f / height
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Boolean] true if video is portrait orientation
|
|
132
|
+
def portrait?
|
|
133
|
+
return false unless video? && aspect_ratio
|
|
134
|
+
|
|
135
|
+
# Account for rotation
|
|
136
|
+
effective_ratio = [90, 270].include?(rotation.to_i) ? 1.0 / aspect_ratio : aspect_ratio
|
|
137
|
+
effective_ratio < 1.0
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] true if video is landscape orientation
|
|
141
|
+
def landscape?
|
|
142
|
+
video? && !portrait?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Boolean] true if video is HD (720p or higher)
|
|
146
|
+
def hd?
|
|
147
|
+
return false unless video?
|
|
148
|
+
|
|
149
|
+
min_dimension = [width, height].compact.min
|
|
150
|
+
min_dimension && min_dimension >= 720
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @return [Boolean] true if video is 4K (2160p or higher)
|
|
154
|
+
def uhd?
|
|
155
|
+
return false unless video?
|
|
156
|
+
|
|
157
|
+
min_dimension = [width, height].compact.min
|
|
158
|
+
min_dimension && min_dimension >= 2160
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @return [String] human-readable description
|
|
162
|
+
def to_s
|
|
163
|
+
parts = ["Stream ##{index}: #{type.capitalize}"]
|
|
164
|
+
parts << codec if codec
|
|
165
|
+
|
|
166
|
+
if video?
|
|
167
|
+
parts << resolution if resolution
|
|
168
|
+
parts << "#{frame_rate}fps" if frame_rate
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if audio?
|
|
172
|
+
parts << "#{sample_rate}Hz" if sample_rate
|
|
173
|
+
parts << channel_layout if channel_layout
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
parts.join(", ")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def parse_video_attributes(data)
|
|
182
|
+
@width = data["width"]&.to_i
|
|
183
|
+
@height = data["height"]&.to_i
|
|
184
|
+
@pixel_format = data["pix_fmt"]
|
|
185
|
+
|
|
186
|
+
# Parse frame rate (can be "30/1" or "30000/1001" format)
|
|
187
|
+
@frame_rate = parse_frame_rate(data["r_frame_rate"])
|
|
188
|
+
@avg_frame_rate = parse_frame_rate(data["avg_frame_rate"])
|
|
189
|
+
|
|
190
|
+
# Parse rotation from side_data_list
|
|
191
|
+
@rotation = parse_rotation(data["side_data_list"])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_audio_attributes(data)
|
|
195
|
+
@sample_rate = data["sample_rate"]&.to_i
|
|
196
|
+
@channels = data["channels"]&.to_i
|
|
197
|
+
@channel_layout = data["channel_layout"]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def parse_tags(tags)
|
|
201
|
+
@language = tags["language"]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def parse_disposition(disposition)
|
|
205
|
+
@default = disposition["default"] == 1
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def parse_frame_rate(rate_string)
|
|
209
|
+
return nil unless rate_string
|
|
210
|
+
|
|
211
|
+
# Handle "30/1" or "30000/1001" format
|
|
212
|
+
if rate_string.include?("/")
|
|
213
|
+
num, den = rate_string.split("/").map(&:to_f)
|
|
214
|
+
return nil if den.zero?
|
|
215
|
+
|
|
216
|
+
(num / den).round(2)
|
|
217
|
+
else
|
|
218
|
+
rate_string.to_f
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def parse_rotation(side_data_list)
|
|
223
|
+
return nil unless side_data_list.is_a?(Array)
|
|
224
|
+
|
|
225
|
+
rotation_data = side_data_list.find { |sd| sd["rotation"] }
|
|
226
|
+
rotation_data&.dig("rotation")&.to_i&.abs
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :ffmpeg do
|
|
4
|
+
desc "Check FFMPEG installation and version"
|
|
5
|
+
task :check do
|
|
6
|
+
require "ffmpeg"
|
|
7
|
+
|
|
8
|
+
puts "Checking FFMPEG installation..."
|
|
9
|
+
puts
|
|
10
|
+
|
|
11
|
+
if FFMPEG.available?
|
|
12
|
+
puts "[OK] FFMPEG is installed"
|
|
13
|
+
puts " Version: #{FFMPEG.version}"
|
|
14
|
+
puts " Binary: #{FFMPEG.ffmpeg_binary}"
|
|
15
|
+
puts " FFprobe: #{FFMPEG.ffprobe_binary}"
|
|
16
|
+
else
|
|
17
|
+
puts "[ERROR] FFMPEG is not installed or not in PATH"
|
|
18
|
+
puts
|
|
19
|
+
puts "Install with:"
|
|
20
|
+
puts " macOS: brew install ffmpeg"
|
|
21
|
+
puts " Ubuntu: sudo apt install ffmpeg"
|
|
22
|
+
puts " Windows: choco install ffmpeg"
|
|
23
|
+
exit 1
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
desc "Analyze a video file"
|
|
28
|
+
task :analyze, [:path] do |_t, args|
|
|
29
|
+
require "ffmpeg"
|
|
30
|
+
|
|
31
|
+
path = args[:path]
|
|
32
|
+
unless path
|
|
33
|
+
puts "Usage: rake ffmpeg:analyze[/path/to/video.mp4]"
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
media = FFMPEG::Media.new(path)
|
|
39
|
+
puts media.to_h.to_yaml
|
|
40
|
+
rescue FFMPEG::Error => e
|
|
41
|
+
puts "Error: #{e.message}"
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc "Extract keyframes from a video"
|
|
47
|
+
task :keyframes, [:path, :output_dir, :interval] do |_t, args|
|
|
48
|
+
require "ffmpeg"
|
|
49
|
+
|
|
50
|
+
path = args[:path]
|
|
51
|
+
output_dir = args[:output_dir] || "./keyframes"
|
|
52
|
+
interval = (args[:interval] || 5).to_f
|
|
53
|
+
|
|
54
|
+
unless path
|
|
55
|
+
puts "Usage: rake ffmpeg:keyframes[/path/to/video.mp4,output_dir,interval]"
|
|
56
|
+
exit 1
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
media = FFMPEG::Media.new(path)
|
|
61
|
+
frames = media.extract_keyframes(output_dir: output_dir, interval: interval)
|
|
62
|
+
puts "Extracted #{frames.count} keyframes to #{output_dir}"
|
|
63
|
+
frames.each { |f| puts " #{f}" }
|
|
64
|
+
rescue FFMPEG::Error => e
|
|
65
|
+
puts "Error: #{e.message}"
|
|
66
|
+
exit 1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
desc "Detect scenes in a video"
|
|
71
|
+
task :scenes, [:path, :threshold] do |_t, args|
|
|
72
|
+
require "ffmpeg"
|
|
73
|
+
|
|
74
|
+
path = args[:path]
|
|
75
|
+
threshold = (args[:threshold] || 0.3).to_f
|
|
76
|
+
|
|
77
|
+
unless path
|
|
78
|
+
puts "Usage: rake ffmpeg:scenes[/path/to/video.mp4,threshold]"
|
|
79
|
+
exit 1
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
begin
|
|
83
|
+
media = FFMPEG::Media.new(path)
|
|
84
|
+
scenes = media.detect_scenes(threshold: threshold)
|
|
85
|
+
puts "Detected #{scenes.count} scenes:"
|
|
86
|
+
scenes.each do |scene|
|
|
87
|
+
puts " #{format_time(scene[:timestamp])} (score: #{scene[:score]&.round(2)})"
|
|
88
|
+
end
|
|
89
|
+
rescue FFMPEG::Error => e
|
|
90
|
+
puts "Error: #{e.message}"
|
|
91
|
+
exit 1
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def format_time(seconds)
|
|
96
|
+
return "0:00" unless seconds
|
|
97
|
+
|
|
98
|
+
hours = (seconds / 3600).floor
|
|
99
|
+
minutes = ((seconds % 3600) / 60).floor
|
|
100
|
+
secs = (seconds % 60).floor
|
|
101
|
+
|
|
102
|
+
if hours.positive?
|
|
103
|
+
format("%d:%02d:%02d", hours, minutes, secs)
|
|
104
|
+
else
|
|
105
|
+
format("%d:%02d", minutes, secs)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FFMPEG
|
|
4
|
+
# Transcodes media files with progress reporting
|
|
5
|
+
#
|
|
6
|
+
# @example Basic transcoding
|
|
7
|
+
# transcoder = Transcoder.new(media, "/output.mp4")
|
|
8
|
+
# transcoder.run do |progress|
|
|
9
|
+
# puts "#{progress}% complete"
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# @example With options
|
|
13
|
+
# transcoder = Transcoder.new(media, "/output.webm",
|
|
14
|
+
# video_codec: "libvpx-vp9",
|
|
15
|
+
# audio_codec: "libopus",
|
|
16
|
+
# video_bitrate: "2M"
|
|
17
|
+
# )
|
|
18
|
+
# result = transcoder.run
|
|
19
|
+
#
|
|
20
|
+
class Transcoder
|
|
21
|
+
# @return [Media] source media
|
|
22
|
+
attr_reader :media
|
|
23
|
+
|
|
24
|
+
# @return [String] output path
|
|
25
|
+
attr_reader :output_path
|
|
26
|
+
|
|
27
|
+
# @return [Hash] transcoding options
|
|
28
|
+
attr_reader :options
|
|
29
|
+
|
|
30
|
+
# Create a new Transcoder
|
|
31
|
+
# @param media [Media] source media
|
|
32
|
+
# @param output_path [String] output file path
|
|
33
|
+
# @param options [Hash] transcoding options
|
|
34
|
+
# @option options [String] :video_codec video codec (default: libx264)
|
|
35
|
+
# @option options [String] :audio_codec audio codec (default: aac)
|
|
36
|
+
# @option options [String] :resolution output resolution (e.g., "1280x720")
|
|
37
|
+
# @option options [String, Float] :frame_rate output frame rate
|
|
38
|
+
# @option options [String] :video_bitrate video bitrate (e.g., "2M")
|
|
39
|
+
# @option options [String] :audio_bitrate audio bitrate (e.g., "128k")
|
|
40
|
+
# @option options [Float] :seek start time in seconds
|
|
41
|
+
# @option options [Float] :duration duration in seconds
|
|
42
|
+
# @option options [Boolean] :copy_video copy video without re-encoding
|
|
43
|
+
# @option options [Boolean] :copy_audio copy audio without re-encoding
|
|
44
|
+
# @option options [Boolean] :no_audio strip audio
|
|
45
|
+
# @option options [Array<String>] :video_filters video filter chain
|
|
46
|
+
# @option options [Array<String>] :audio_filters audio filter chain
|
|
47
|
+
# @option options [String] :format output format
|
|
48
|
+
# @option options [Array<String>] :custom custom FFmpeg arguments
|
|
49
|
+
# @option options [Integer] :threads number of threads
|
|
50
|
+
def initialize(media, output_path, **options)
|
|
51
|
+
@media = media
|
|
52
|
+
@output_path = File.expand_path(output_path)
|
|
53
|
+
@options = default_options.merge(options)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Run the transcoding
|
|
57
|
+
# @yield [Float] progress percentage (0-100)
|
|
58
|
+
# @return [Media] the output media
|
|
59
|
+
# @raise [TranscodingError] if transcoding fails
|
|
60
|
+
def run(&block)
|
|
61
|
+
ensure_output_directory!
|
|
62
|
+
|
|
63
|
+
cmd = build_command
|
|
64
|
+
total_duration = calculate_duration
|
|
65
|
+
|
|
66
|
+
Command.run!(*cmd) do |line|
|
|
67
|
+
if block_given? && total_duration&.positive?
|
|
68
|
+
progress = parse_progress(line, total_duration)
|
|
69
|
+
yield progress if progress
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
yield 100.0 if block_given?
|
|
74
|
+
|
|
75
|
+
Media.new(output_path)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Get the FFmpeg command that will be executed
|
|
79
|
+
# @return [Array<String>]
|
|
80
|
+
def command
|
|
81
|
+
build_command
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Preview the command as a string
|
|
85
|
+
# @return [String]
|
|
86
|
+
def command_preview
|
|
87
|
+
build_command.join(" ")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def default_options
|
|
93
|
+
config = FFMPEG.configuration
|
|
94
|
+
{
|
|
95
|
+
video_codec: config.default_video_codec,
|
|
96
|
+
audio_codec: config.default_audio_codec,
|
|
97
|
+
threads: config.threads
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_command
|
|
102
|
+
Command.build_ffmpeg_command(
|
|
103
|
+
input: media.path,
|
|
104
|
+
output: output_path,
|
|
105
|
+
**options
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ensure_output_directory!
|
|
110
|
+
dir = File.dirname(output_path)
|
|
111
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def calculate_duration
|
|
115
|
+
if options[:duration]
|
|
116
|
+
options[:duration].to_f
|
|
117
|
+
elsif options[:seek] && media.duration
|
|
118
|
+
media.duration - options[:seek].to_f
|
|
119
|
+
else
|
|
120
|
+
media.duration
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_progress(line, total_duration)
|
|
125
|
+
# FFmpeg outputs progress to stderr in format:
|
|
126
|
+
# frame= 123 fps= 30 q=28.0 size= 1234kB time=00:00:04.56 bitrate=2234.5kbits/s
|
|
127
|
+
return nil unless line.include?("time=")
|
|
128
|
+
|
|
129
|
+
match = line.match(/time=(\d+):(\d+):(\d+(?:\.\d+)?)/)
|
|
130
|
+
return nil unless match
|
|
131
|
+
|
|
132
|
+
hours, minutes, seconds = match.captures.map(&:to_f)
|
|
133
|
+
current_time = hours * 3600 + minutes * 60 + seconds
|
|
134
|
+
|
|
135
|
+
progress = (current_time / total_duration * 100).round(1)
|
|
136
|
+
progress.clamp(0.0, 100.0)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/ffmpeg.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ffmpeg/version"
|
|
4
|
+
require_relative "ffmpeg/errors"
|
|
5
|
+
require_relative "ffmpeg/configuration"
|
|
6
|
+
require_relative "ffmpeg/command"
|
|
7
|
+
require_relative "ffmpeg/media"
|
|
8
|
+
require_relative "ffmpeg/stream"
|
|
9
|
+
require_relative "ffmpeg/transcoder"
|
|
10
|
+
require_relative "ffmpeg/scene_detector"
|
|
11
|
+
require_relative "ffmpeg/keyframe_extractor"
|
|
12
|
+
|
|
13
|
+
# Load Rails integration if Rails is available
|
|
14
|
+
if defined?(Rails::Railtie)
|
|
15
|
+
require_relative "ffmpeg/railtie"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Load Active Storage integration if Active Storage is available
|
|
19
|
+
if defined?(ActiveStorage)
|
|
20
|
+
require_relative "ffmpeg/active_storage/analyzer"
|
|
21
|
+
require_relative "ffmpeg/active_storage/previewer"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# FFMPEG - A modern Ruby wrapper for FFmpeg
|
|
25
|
+
#
|
|
26
|
+
# @example Basic usage
|
|
27
|
+
# media = FFMPEG::Media.new("/path/to/video.mp4")
|
|
28
|
+
# media.duration # => 120.5
|
|
29
|
+
# media.resolution # => "1920x1080"
|
|
30
|
+
# media.video_codec # => "h264"
|
|
31
|
+
#
|
|
32
|
+
# @example Transcoding
|
|
33
|
+
# media.transcode("/path/to/output.mp4") do |progress|
|
|
34
|
+
# puts "Progress: #{progress}%"
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# @example Scene detection
|
|
38
|
+
# detector = FFMPEG::SceneDetector.new(media)
|
|
39
|
+
# scenes = detector.detect(threshold: 0.3)
|
|
40
|
+
# # => [{ timestamp: 0.0 }, { timestamp: 5.2 }, ...]
|
|
41
|
+
#
|
|
42
|
+
# @example Keyframe extraction
|
|
43
|
+
# extractor = FFMPEG::KeyframeExtractor.new(media)
|
|
44
|
+
# frames = extractor.extract_at_intervals(interval: 5.0, output_dir: "/tmp/frames")
|
|
45
|
+
# # => ["/tmp/frames/frame_0000.jpg", ...]
|
|
46
|
+
#
|
|
47
|
+
module FFMPEG
|
|
48
|
+
class << self
|
|
49
|
+
# Global configuration
|
|
50
|
+
# @return [Configuration]
|
|
51
|
+
def configuration
|
|
52
|
+
@configuration ||= Configuration.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Configure FFMPEG
|
|
56
|
+
# @yield [Configuration]
|
|
57
|
+
def configure
|
|
58
|
+
yield(configuration)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Path to ffmpeg binary
|
|
62
|
+
# @return [String]
|
|
63
|
+
def ffmpeg_binary
|
|
64
|
+
configuration.ffmpeg_binary
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Path to ffprobe binary
|
|
68
|
+
# @return [String]
|
|
69
|
+
def ffprobe_binary
|
|
70
|
+
configuration.ffprobe_binary
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Logger instance
|
|
74
|
+
# @return [Logger, nil]
|
|
75
|
+
def logger
|
|
76
|
+
configuration.logger
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check if FFmpeg is installed and accessible
|
|
80
|
+
# @return [Boolean]
|
|
81
|
+
def available?
|
|
82
|
+
Command.run(ffmpeg_binary, "-version").success?
|
|
83
|
+
rescue Errno::ENOENT
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get FFmpeg version
|
|
88
|
+
# @return [String, nil]
|
|
89
|
+
def version
|
|
90
|
+
result = Command.run(ffmpeg_binary, "-version")
|
|
91
|
+
return nil unless result.success?
|
|
92
|
+
|
|
93
|
+
result.output.match(/ffmpeg version (\S+)/i)&.[](1)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/ruby-ffmpeg.gemspec
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/ffmpeg/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "ruby-ffmpeg"
|
|
7
|
+
spec.version = FFMPEG::VERSION
|
|
8
|
+
spec.authors = ["Active Agents"]
|
|
9
|
+
spec.email = ["hello@activeagents.ai"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Modern Ruby wrapper for FFmpeg with scene detection and keyframe extraction"
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
A clean, well-tested FFmpeg wrapper with modern Ruby conventions, proper error handling,
|
|
14
|
+
and zero dependencies. Features include metadata extraction, transcoding with progress
|
|
15
|
+
reporting, scene detection, keyframe extraction, and Rails/Active Storage integration.
|
|
16
|
+
Tested against FFmpeg 4, 5, 6, and 7.
|
|
17
|
+
DESC
|
|
18
|
+
spec.homepage = "https://github.com/activeagents/ruby-ffmpeg"
|
|
19
|
+
spec.license = "MIT"
|
|
20
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
21
|
+
|
|
22
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
23
|
+
spec.metadata["source_code_uri"] = "https://github.com/activeagents/ruby-ffmpeg"
|
|
24
|
+
spec.metadata["changelog_uri"] = "https://github.com/activeagents/ruby-ffmpeg/blob/main/CHANGELOG.md"
|
|
25
|
+
spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/ruby-ffmpeg"
|
|
26
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/activeagents/ruby-ffmpeg/issues"
|
|
27
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
28
|
+
|
|
29
|
+
# Specify which files should be added to the gem when it is released
|
|
30
|
+
spec.files = Dir.chdir(__dir__) do
|
|
31
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
32
|
+
(File.expand_path(f) == __FILE__) ||
|
|
33
|
+
f.start_with?(*%w[bin/ test/ spec/ .git .github .rubocop Gemfile])
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
spec.bindir = "exe"
|
|
38
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
39
|
+
spec.require_paths = ["lib"]
|
|
40
|
+
|
|
41
|
+
# No runtime dependencies - just pure Ruby!
|
|
42
|
+
# FFmpeg binary must be installed separately
|
|
43
|
+
|
|
44
|
+
# Development dependencies
|
|
45
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
46
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
47
|
+
spec.add_development_dependency "rubocop", "~> 1.21"
|
|
48
|
+
spec.add_development_dependency "yard", "~> 0.9"
|
|
49
|
+
end
|