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,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
|