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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module FFMPEG
7
+ # Safely executes shell commands with timeout and proper error handling
8
+ #
9
+ # @example Basic usage
10
+ # result = Command.run("ffmpeg", "-i", "input.mp4", "-c", "copy", "output.mp4")
11
+ # result.success? # => true
12
+ # result.output # => "..."
13
+ #
14
+ # @example With progress callback
15
+ # Command.run("ffmpeg", "-i", "input.mp4", "output.mp4") do |line|
16
+ # # Parse progress from stderr
17
+ # if (match = line.match(/time=(\d+:\d+:\d+\.\d+)/))
18
+ # puts "Progress: #{match[1]}"
19
+ # end
20
+ # end
21
+ #
22
+ class Command
23
+ # Result of a command execution
24
+ Result = Data.define(:output, :error, :exit_code) do
25
+ def success?
26
+ exit_code.zero?
27
+ end
28
+
29
+ def failure?
30
+ !success?
31
+ end
32
+ end
33
+
34
+ class << self
35
+ # Run a command with arguments
36
+ # @param args [Array<String>] command and arguments
37
+ # @param timeout [Integer, nil] timeout in seconds
38
+ # @param env [Hash] environment variables
39
+ # @yield [String] each line of output for progress tracking
40
+ # @return [Result]
41
+ def run(*args, timeout: nil, env: {}, &block)
42
+ timeout ||= FFMPEG.configuration.timeout
43
+
44
+ log_command(args)
45
+
46
+ output = +""
47
+ error = +""
48
+ exit_status = nil
49
+
50
+ begin
51
+ Timeout.timeout(timeout) do
52
+ Open3.popen3(env, *args) do |stdin, stdout, stderr, wait_thr|
53
+ stdin.close
54
+
55
+ # Read stdout and stderr in threads to avoid blocking
56
+ stdout_thread = Thread.new do
57
+ stdout.each_line do |line|
58
+ output << line
59
+ yield line if block_given?
60
+ end
61
+ end
62
+
63
+ stderr_thread = Thread.new do
64
+ stderr.each_line do |line|
65
+ error << line
66
+ yield line if block_given?
67
+ end
68
+ end
69
+
70
+ stdout_thread.join
71
+ stderr_thread.join
72
+
73
+ exit_status = wait_thr.value
74
+ end
75
+ end
76
+ rescue Timeout::Error
77
+ raise CommandTimeout.new(args.join(" "), timeout)
78
+ end
79
+
80
+ log_result(output, error, exit_status&.exitstatus || 1)
81
+
82
+ Result.new(
83
+ output: output,
84
+ error: error,
85
+ exit_code: exit_status&.exitstatus || 1
86
+ )
87
+ end
88
+
89
+ # Run a command and raise on failure
90
+ # @param args [Array<String>] command and arguments
91
+ # @param timeout [Integer, nil] timeout in seconds
92
+ # @param env [Hash] environment variables
93
+ # @yield [String] each line of output
94
+ # @return [Result]
95
+ # @raise [TranscodingError] if command fails
96
+ def run!(*args, timeout: nil, env: {}, &block)
97
+ result = run(*args, timeout: timeout, env: env, &block)
98
+
99
+ unless result.success?
100
+ raise TranscodingError.new(
101
+ "Command failed with exit code #{result.exit_code}",
102
+ command: args.join(" "),
103
+ output: result.error,
104
+ exit_code: result.exit_code
105
+ )
106
+ end
107
+
108
+ result
109
+ end
110
+
111
+ # Build FFmpeg command with common options
112
+ # @param input [String] input file path
113
+ # @param output [String] output file path
114
+ # @param options [Hash] FFmpeg options
115
+ # @return [Array<String>]
116
+ def build_ffmpeg_command(input:, output:, **options)
117
+ cmd = [FFMPEG.ffmpeg_binary]
118
+
119
+ # Overwrite output
120
+ cmd << "-y" if FFMPEG.configuration.overwrite_output
121
+
122
+ # Input options (before -i)
123
+ if options[:seek]
124
+ cmd += ["-ss", options[:seek].to_s]
125
+ end
126
+
127
+ if options[:duration]
128
+ cmd += ["-t", options[:duration].to_s]
129
+ end
130
+
131
+ # Input file
132
+ cmd += ["-i", input]
133
+
134
+ # Video codec
135
+ if options[:video_codec]
136
+ cmd += ["-c:v", options[:video_codec]]
137
+ elsif options[:copy_video]
138
+ cmd += ["-c:v", "copy"]
139
+ end
140
+
141
+ # Audio codec
142
+ if options[:audio_codec]
143
+ cmd += ["-c:a", options[:audio_codec]]
144
+ elsif options[:copy_audio]
145
+ cmd += ["-c:a", "copy"]
146
+ elsif options[:no_audio]
147
+ cmd << "-an"
148
+ end
149
+
150
+ # Video filters
151
+ if options[:video_filters]&.any?
152
+ cmd += ["-vf", options[:video_filters].join(",")]
153
+ end
154
+
155
+ # Audio filters
156
+ if options[:audio_filters]&.any?
157
+ cmd += ["-af", options[:audio_filters].join(",")]
158
+ end
159
+
160
+ # Resolution
161
+ if options[:resolution]
162
+ cmd += ["-s", options[:resolution]]
163
+ end
164
+
165
+ # Frame rate
166
+ if options[:frame_rate]
167
+ cmd += ["-r", options[:frame_rate].to_s]
168
+ end
169
+
170
+ # Bitrate
171
+ if options[:video_bitrate]
172
+ cmd += ["-b:v", options[:video_bitrate]]
173
+ end
174
+
175
+ if options[:audio_bitrate]
176
+ cmd += ["-b:a", options[:audio_bitrate]]
177
+ end
178
+
179
+ # Threads
180
+ threads = options[:threads] || FFMPEG.configuration.threads
181
+ cmd += ["-threads", threads.to_s] if threads.positive?
182
+
183
+ # Custom options
184
+ if options[:custom]
185
+ cmd += Array(options[:custom]).flat_map { |opt| opt.split }
186
+ end
187
+
188
+ # Output format
189
+ if options[:format]
190
+ cmd += ["-f", options[:format]]
191
+ end
192
+
193
+ # Output file
194
+ cmd << output
195
+
196
+ cmd
197
+ end
198
+
199
+ private
200
+
201
+ def log_command(args)
202
+ return unless FFMPEG.logger
203
+
204
+ FFMPEG.logger.debug { "[FFMPEG] Running: #{args.join(' ')}" }
205
+ end
206
+
207
+ def log_result(output, error, exit_code)
208
+ return unless FFMPEG.logger
209
+
210
+ if exit_code.zero?
211
+ FFMPEG.logger.debug { "[FFMPEG] Success" }
212
+ else
213
+ FFMPEG.logger.error { "[FFMPEG] Failed (exit #{exit_code}): #{error}" }
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module FFMPEG
6
+ # Configuration options for FFMPEG
7
+ #
8
+ # @example
9
+ # FFMPEG.configure do |config|
10
+ # config.ffmpeg_binary = "/usr/local/bin/ffmpeg"
11
+ # config.ffprobe_binary = "/usr/local/bin/ffprobe"
12
+ # config.timeout = 300
13
+ # config.logger = Rails.logger
14
+ # end
15
+ #
16
+ class Configuration
17
+ # Path to ffmpeg binary
18
+ # @return [String]
19
+ attr_accessor :ffmpeg_binary
20
+
21
+ # Path to ffprobe binary
22
+ # @return [String]
23
+ attr_accessor :ffprobe_binary
24
+
25
+ # Default timeout for commands in seconds
26
+ # @return [Integer]
27
+ attr_accessor :timeout
28
+
29
+ # Logger for debug output
30
+ # @return [Logger, nil]
31
+ attr_accessor :logger
32
+
33
+ # Default video codec for transcoding
34
+ # @return [String]
35
+ attr_accessor :default_video_codec
36
+
37
+ # Default audio codec for transcoding
38
+ # @return [String]
39
+ attr_accessor :default_audio_codec
40
+
41
+ # Default output format
42
+ # @return [String]
43
+ attr_accessor :default_format
44
+
45
+ # Number of threads to use (0 = auto)
46
+ # @return [Integer]
47
+ attr_accessor :threads
48
+
49
+ # Hardware acceleration type (nil, :cuda, :videotoolbox, :vaapi, :qsv)
50
+ # @return [Symbol, nil]
51
+ attr_accessor :hardware_acceleration
52
+
53
+ # Temporary directory for intermediate files
54
+ # @return [String]
55
+ attr_accessor :temp_dir
56
+
57
+ # Whether to overwrite existing output files
58
+ # @return [Boolean]
59
+ attr_accessor :overwrite_output
60
+
61
+ def initialize
62
+ @ffmpeg_binary = find_binary("ffmpeg")
63
+ @ffprobe_binary = find_binary("ffprobe")
64
+ @timeout = 600 # 10 minutes default
65
+ @logger = nil
66
+ @default_video_codec = "libx264"
67
+ @default_audio_codec = "aac"
68
+ @default_format = "mp4"
69
+ @threads = 0 # auto
70
+ @hardware_acceleration = nil
71
+ @temp_dir = Dir.tmpdir
72
+ @overwrite_output = true
73
+ end
74
+
75
+ # Reset configuration to defaults
76
+ def reset!
77
+ initialize
78
+ end
79
+
80
+ # Validate configuration
81
+ # @raise [FFmpegNotFound] if ffmpeg binary not found
82
+ # @raise [FFprobeNotFound] if ffprobe binary not found
83
+ def validate!
84
+ raise FFmpegNotFound, @ffmpeg_binary unless binary_exists?(@ffmpeg_binary)
85
+ raise FFprobeNotFound, @ffprobe_binary unless binary_exists?(@ffprobe_binary)
86
+
87
+ true
88
+ end
89
+
90
+ private
91
+
92
+ def find_binary(name)
93
+ # Check common locations
94
+ paths = [
95
+ name, # Use PATH
96
+ "/usr/bin/#{name}",
97
+ "/usr/local/bin/#{name}",
98
+ "/opt/homebrew/bin/#{name}",
99
+ "/opt/local/bin/#{name}"
100
+ ]
101
+
102
+ paths.find { |path| binary_exists?(path) } || name
103
+ end
104
+
105
+ def binary_exists?(path)
106
+ return false if path.nil? || path.empty?
107
+
108
+ # Check if it's in PATH or is an absolute path that exists
109
+ system("which", path, out: File::NULL, err: File::NULL) ||
110
+ (File.absolute_path?(path) && File.executable?(path))
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FFMPEG
4
+ # Base error class for all FFMPEG errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when FFmpeg binary is not found
8
+ class FFmpegNotFound < Error
9
+ def initialize(binary = "ffmpeg")
10
+ super("#{binary} not found. Please install FFmpeg: https://ffmpeg.org/download.html")
11
+ end
12
+ end
13
+
14
+ # Raised when FFprobe binary is not found
15
+ class FFprobeNotFound < Error
16
+ def initialize(binary = "ffprobe")
17
+ super("#{binary} not found. Please install FFmpeg: https://ffmpeg.org/download.html")
18
+ end
19
+ end
20
+
21
+ # Raised when media file is not found or inaccessible
22
+ class MediaNotFound < Error
23
+ def initialize(path)
24
+ super("Media file not found: #{path}")
25
+ end
26
+ end
27
+
28
+ # Raised when media file is invalid or corrupted
29
+ class InvalidMedia < Error
30
+ def initialize(path, reason = nil)
31
+ message = "Invalid media file: #{path}"
32
+ message += " (#{reason})" if reason
33
+ super(message)
34
+ end
35
+ end
36
+
37
+ # Raised when transcoding fails
38
+ class TranscodingError < Error
39
+ attr_reader :command, :output, :exit_code
40
+
41
+ def initialize(message, command: nil, output: nil, exit_code: nil)
42
+ @command = command
43
+ @output = output
44
+ @exit_code = exit_code
45
+ super(message)
46
+ end
47
+ end
48
+
49
+ # Raised when a command times out
50
+ class CommandTimeout < Error
51
+ attr_reader :command, :timeout
52
+
53
+ def initialize(command, timeout)
54
+ @command = command
55
+ @timeout = timeout
56
+ super("Command timed out after #{timeout}s: #{command}")
57
+ end
58
+ end
59
+
60
+ # Raised when scene detection fails
61
+ class SceneDetectionError < Error; end
62
+
63
+ # Raised when keyframe extraction fails
64
+ class KeyframeExtractionError < Error; end
65
+
66
+ # Raised when preset is not found
67
+ class PresetNotFound < Error
68
+ def initialize(preset_name)
69
+ super("Preset not found: #{preset_name}")
70
+ end
71
+ end
72
+
73
+ # Raised when filter is invalid
74
+ class InvalidFilter < Error
75
+ def initialize(filter_name, reason = nil)
76
+ message = "Invalid filter: #{filter_name}"
77
+ message += " (#{reason})" if reason
78
+ super(message)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module FFMPEG
6
+ # Extracts keyframes/thumbnails from video files
7
+ #
8
+ # @example Extract at intervals
9
+ # extractor = KeyframeExtractor.new(media)
10
+ # frames = extractor.extract_at_intervals(interval: 5.0, output_dir: "/tmp/frames")
11
+ # # => ["/tmp/frames/frame_0000.jpg", "/tmp/frames/frame_0005.jpg", ...]
12
+ #
13
+ # @example Extract specific count
14
+ # frames = extractor.extract_count(10, output_dir: "/tmp/frames")
15
+ # # => ["/tmp/frames/frame_0000.jpg", ..., "/tmp/frames/frame_0009.jpg"]
16
+ #
17
+ # @example Extract at specific timestamps
18
+ # frames = extractor.extract_at_timestamps([0, 30, 60, 90], output_dir: "/tmp/frames")
19
+ #
20
+ class KeyframeExtractor
21
+ # Supported output formats
22
+ FORMATS = %w[jpg jpeg png bmp webp].freeze
23
+
24
+ # @return [Media] source media
25
+ attr_reader :media
26
+
27
+ # Create a new KeyframeExtractor
28
+ # @param media [Media] source media
29
+ def initialize(media)
30
+ @media = media
31
+ validate_media!
32
+ end
33
+
34
+ # Extract frames at regular intervals
35
+ # @param interval [Float] interval between frames in seconds
36
+ # @param output_dir [String] directory for output frames
37
+ # @param format [String] output image format
38
+ # @param quality [Integer] JPEG quality (1-31 for ffmpeg, lower is better)
39
+ # @param resolution [String, nil] output resolution (e.g., "640x480")
40
+ # @return [Array<String>] paths to extracted frames
41
+ def extract_at_intervals(interval: 5.0, output_dir:, format: "jpg", quality: 2, resolution: nil)
42
+ ensure_output_dir!(output_dir)
43
+ validate_format!(format)
44
+
45
+ # Calculate timestamps
46
+ timestamps = []
47
+ current = 0.0
48
+ while current < media.duration
49
+ timestamps << current
50
+ current += interval
51
+ end
52
+
53
+ extract_frames(timestamps, output_dir, format, quality, resolution)
54
+ end
55
+
56
+ # Extract a specific number of frames evenly distributed
57
+ # @param count [Integer] number of frames to extract
58
+ # @param output_dir [String] directory for output frames
59
+ # @param format [String] output image format
60
+ # @param quality [Integer] JPEG quality
61
+ # @param resolution [String, nil] output resolution
62
+ # @return [Array<String>] paths to extracted frames
63
+ def extract_count(count, output_dir:, format: "jpg", quality: 2, resolution: nil)
64
+ ensure_output_dir!(output_dir)
65
+ validate_format!(format)
66
+
67
+ return [] if count <= 0
68
+
69
+ # Calculate evenly distributed timestamps
70
+ duration = media.duration || 0
71
+ timestamps = if count == 1
72
+ [0.0]
73
+ else
74
+ interval = duration / (count - 1)
75
+ (0...count).map { |i| i * interval }
76
+ end
77
+
78
+ extract_frames(timestamps, output_dir, format, quality, resolution)
79
+ end
80
+
81
+ # Extract frames at specific timestamps
82
+ # @param timestamps [Array<Float>] timestamps in seconds
83
+ # @param output_dir [String] directory for output frames
84
+ # @param format [String] output image format
85
+ # @param quality [Integer] JPEG quality
86
+ # @param resolution [String, nil] output resolution
87
+ # @return [Array<String>] paths to extracted frames
88
+ def extract_at_timestamps(timestamps, output_dir:, format: "jpg", quality: 2, resolution: nil)
89
+ ensure_output_dir!(output_dir)
90
+ validate_format!(format)
91
+
92
+ extract_frames(timestamps.sort, output_dir, format, quality, resolution)
93
+ end
94
+
95
+ # Extract actual I-frames (keyframes) from the video
96
+ # @param output_dir [String] directory for output frames
97
+ # @param format [String] output image format
98
+ # @param quality [Integer] JPEG quality
99
+ # @param max_frames [Integer, nil] maximum number of frames
100
+ # @return [Array<String>] paths to extracted frames
101
+ def extract_iframes(output_dir:, format: "jpg", quality: 2, max_frames: nil)
102
+ ensure_output_dir!(output_dir)
103
+ validate_format!(format)
104
+
105
+ output_pattern = File.join(output_dir, "iframe_%04d.#{format}")
106
+
107
+ cmd = [
108
+ FFMPEG.ffmpeg_binary,
109
+ "-i", media.path,
110
+ "-vf", "select='eq(pict_type,I)'",
111
+ "-vsync", "vfr",
112
+ "-q:v", quality.to_s
113
+ ]
114
+
115
+ cmd += ["-frames:v", max_frames.to_s] if max_frames
116
+ cmd += ["-y", output_pattern]
117
+
118
+ result = Command.run!(*cmd)
119
+
120
+ # Return list of created files
121
+ Dir.glob(File.join(output_dir, "iframe_*.#{format}")).sort
122
+ end
123
+
124
+ # Create a thumbnail sprite sheet (useful for video scrubbing)
125
+ # @param columns [Integer] number of columns in sprite
126
+ # @param rows [Integer] number of rows in sprite
127
+ # @param width [Integer] thumbnail width
128
+ # @param output_path [String] output sprite path
129
+ # @return [Hash] sprite info with :path, :columns, :rows, :thumbnail_width, :thumbnail_height
130
+ def create_sprite(columns: 10, rows: 10, width: 160, output_path:)
131
+ total_frames = columns * rows
132
+ interval = (media.duration || 0) / total_frames
133
+
134
+ # Calculate thumbnail height maintaining aspect ratio
135
+ aspect_ratio = (media.width.to_f / media.height) rescue 16.0/9
136
+ height = (width / aspect_ratio).round
137
+
138
+ tile = "#{columns}x#{rows}"
139
+ scale = "scale=#{width}:#{height}"
140
+
141
+ cmd = [
142
+ FFMPEG.ffmpeg_binary,
143
+ "-i", media.path,
144
+ "-vf", "fps=1/#{interval},#{scale},tile=#{tile}",
145
+ "-frames:v", "1",
146
+ "-y",
147
+ output_path
148
+ ]
149
+
150
+ Command.run!(*cmd)
151
+
152
+ {
153
+ path: output_path,
154
+ columns: columns,
155
+ rows: rows,
156
+ thumbnail_width: width,
157
+ thumbnail_height: height,
158
+ interval: interval
159
+ }
160
+ end
161
+
162
+ private
163
+
164
+ def validate_media!
165
+ raise KeyframeExtractionError, "Media has no video stream" unless media.video?
166
+ end
167
+
168
+ def validate_format!(format)
169
+ unless FORMATS.include?(format.downcase)
170
+ raise KeyframeExtractionError, "Unsupported format: #{format}. Supported: #{FORMATS.join(', ')}"
171
+ end
172
+ end
173
+
174
+ def ensure_output_dir!(dir)
175
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
176
+ end
177
+
178
+ def extract_frames(timestamps, output_dir, format, quality, resolution)
179
+ frames = []
180
+
181
+ timestamps.each_with_index do |timestamp, index|
182
+ output_path = File.join(output_dir, "frame_%04d.#{format}" % index)
183
+ extract_single_frame(timestamp, output_path, quality, resolution)
184
+ frames << output_path if File.exist?(output_path)
185
+ end
186
+
187
+ frames
188
+ end
189
+
190
+ def extract_single_frame(timestamp, output_path, quality, resolution)
191
+ cmd = [
192
+ FFMPEG.ffmpeg_binary,
193
+ "-ss", format_timestamp(timestamp),
194
+ "-i", media.path,
195
+ "-vframes", "1",
196
+ "-q:v", quality.to_s
197
+ ]
198
+
199
+ cmd += ["-s", resolution] if resolution
200
+ cmd += ["-y", output_path]
201
+
202
+ Command.run(*cmd)
203
+ end
204
+
205
+ def format_timestamp(seconds)
206
+ hours = (seconds / 3600).floor
207
+ minutes = ((seconds % 3600) / 60).floor
208
+ secs = seconds % 60
209
+
210
+ format("%02d:%02d:%06.3f", hours, minutes, secs)
211
+ end
212
+ end
213
+ end