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,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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FFMPEG
4
+ VERSION = "0.1.0"
5
+
6
+ # Minimum supported FFmpeg version
7
+ MIN_FFMPEG_VERSION = "4.0"
8
+
9
+ # Tested FFmpeg versions
10
+ TESTED_FFMPEG_VERSIONS = %w[4 5 6 7].freeze
11
+ 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
@@ -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