Skip to content

Commit ffc3c70

Browse files
authored
feat!: add non-blocking popen3 methods (#9)
* feat!: add non-blocking popen3 methods BREAKING CHANGE: Dropped support for Ruby 3.1. * docs: update docblocks on some methods * fix: resolve RuboCop errors in the DASH preset class * fix: resolve encoding issues in capture3 methods * ci: add Ruby 3.4 to the test matrix * docs: add CHANGELOG entry for 7.0.0-beta * chore: update version to 7.0.0-beta.1 and document changes
1 parent cd80633 commit ffc3c70

File tree

14 files changed

+130
-176
lines changed

14 files changed

+130
-176
lines changed

.github/workflows/ci.lint.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
- name: Set up Ruby
1919
uses: ruby/setup-ruby@v1
2020
with:
21-
ruby-version: '3.3'
21+
ruby-version: '3.2'
2222
bundler-cache: true
2323
rubygems: latest
2424

.github/workflows/ci.test.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
runs-on: ubuntu-latest
1515
strategy:
1616
matrix:
17-
ruby-version: ['3.1', '3.2', '3.3']
17+
ruby-version: ['3.2', '3.3', '3.4']
1818
ffmpeg-version: ['release', '6.0.1', '5.1.1', '4.4.1']
1919

2020
include:

.rubocop.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ inherit_from: .rubocop_todo.yml
33
AllCops:
44
NewCops: enable
55
SuggestExtensions: false
6-
TargetRubyVersion: 3.1
6+
TargetRubyVersion: 3.2
77

88
Metrics/AbcSize:
99
Enabled: false

CHANGELOG

+27
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,30 @@
1+
== 7.0.0-beta.1 2025-01-22
2+
3+
Fixes:
4+
* Some incorrect or incomplete documentation comments.
5+
6+
Improvements:
7+
* Added non-blocking popen3 methods for both ffmpeg and ffprobe.
8+
* Improved IO handling for ffmpeg and ffprobe processes.
9+
10+
Breaking Changes:
11+
* Dropped support for Ruby 3.1, Ruby 3.2 is now the minimum supported version.
12+
13+
== 7.0.0-beta 2024-11-29
14+
15+
Breaking Changes:
16+
* Added new, more powerful (though less extensible) DSL to build ffmpeg commands.
17+
* Introduced new concept of long-lived presets and transcoders.
18+
* Removed full output storage during the transcoding process.
19+
* Added built-in presets that can be used out-of-the-box:
20+
* H.264 360p all the way up to 4K resolution.
21+
* AAC 128k all the way up to 320k bit rate.
22+
* DASH H.264 360p all the way up to 4K resolution.
23+
* DASH AAC 128k all the way up to 320k bit rate.
24+
* Dropped support for Ruby 3.0, Ruby 3.1 is now the minimum supported version.
25+
126
== 6.1.2 2024-11-07
27+
228
Fixes:
329
* Calculate rotation correctly for media files with multiple side data elements
430

@@ -9,6 +35,7 @@ Fixes:
935

1036
== 6.1.0 2024-10-24
1137

38+
Improvements:
1239
* Added new `default?` and `attached_pic?` helper methods to FFMPEG::Stream objects
1340
* Added new `audio_with_attached_pic?` helper method to FFMPEG::Media objects
1441

ffmpeg.gemspec

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Gem::Specification.new do |s|
1313
s.homepage = 'https://github.com/instructure/ffmpeg'
1414
s.summary = 'Wraps ffmpeg to read metadata and transcodes videos.'
1515

16-
s.required_ruby_version = '>= 3.1'
16+
s.required_ruby_version = '>= 3.2'
1717

1818
s.add_dependency('multi_json', '~> 1.8')
1919

lib/ffmpeg.rb

+38-35
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
$LOAD_PATH.unshift File.dirname(__FILE__)
44

55
require 'logger'
6-
require 'open3'
76

87
require_relative 'ffmpeg/command_args'
98
require_relative 'ffmpeg/errors'
@@ -38,18 +37,20 @@
3837
end
3938
end
4039

41-
# The FFMPEG module allows you to customise the behaviour of the FFMPEG library.
40+
# The FFMPEG module allows you to customise the behaviour of the FFMPEG library,
41+
# and provides a set of methods to directly interact with the ffmpeg and ffprobe binaries.
4242
#
4343
# @example
4444
# FFMPEG.logger = Logger.new($stdout)
4545
# FFMPEG.io_timeout = 60
46+
# FFMPEG.io_encoding = Encoding::UTF_8
4647
# FFMPEG.ffmpeg_binary = '/usr/local/bin/ffmpeg'
4748
# FFMPEG.ffprobe_binary = '/usr/local/bin/ffprobe'
4849
module FFMPEG
4950
SIGKILL = RUBY_PLATFORM =~ /(win|w)(32|64)$/ ? 1 : 'SIGKILL'
5051

5152
class << self
52-
attr_writer :logger, :io_timeout
53+
attr_writer :logger
5354

5455
# Get the FFMPEG logger.
5556
#
@@ -59,14 +60,29 @@ def logger
5960
end
6061

6162
# Get the timeout that's used when waiting for ffmpeg output.
62-
# This timeout is used by ffmpeg_execute calls and the Transcoder class.
6363
# Defaults to 30 seconds.
6464
#
6565
# @return [Integer]
6666
def io_timeout
67-
return @io_timeout if defined?(@io_timeout)
67+
FFMPEG::IO.timeout
68+
end
69+
70+
# Set the timeout that's used when waiting for ffmpeg output.
71+
def io_timeout=(timeout)
72+
FFMPEG::IO.timeout = timeout
73+
end
74+
75+
# Get the encoding that's used when reading ffmpeg output.
76+
# Defaults to UTF-8.
77+
#
78+
# @return [Encoding]
79+
def io_encoding
80+
FFMPEG::IO.encoding
81+
end
6882

69-
@io_timeout = 30
83+
# Set the encoding that's used when reading ffmpeg output.
84+
def io_encoding=(encoding)
85+
FFMPEG::IO.encoding = encoding
7086
end
7187

7288
# Set the path to the ffmpeg binary.
@@ -93,34 +109,27 @@ def ffmpeg_binary
93109

94110
# Safely captures the standard output and the standard error of the ffmpeg command.
95111
#
96-
# @return [Array] The standard output, the standard error, and the process status.
112+
# @param args [Array<String>] The arguments to pass to ffmpeg.
113+
# @return [Array<String, Process::Status>] The standard output, the standard error, and the process status.
97114
def ffmpeg_capture3(*args)
98115
logger.debug(self) { "ffmpeg -y #{args.join(' ')}" }
99-
stdout, stderr, status = Open3.capture3(ffmpeg_binary, '-y', *args)
100-
FFMPEG::IO.encode!(stdout)
101-
FFMPEG::IO.encode!(stderr)
102-
[stdout, stderr, status]
116+
FFMPEG::IO.capture3(ffmpeg_binary, '-y', *args)
103117
end
104118

105119
# Starts a new ffmpeg process with the given arguments.
106120
# Yields the standard input, the standard output
107121
# and the standard error streams, as well as the child process
108122
# to the specified block.
109123
#
124+
# @param args [Array<String>] The arguments to pass to ffmpeg.
110125
# @yieldparam stdin (+IO+) The standard input stream.
111126
# @yieldparam stdout (+FFMPEG::IO+) The standard output stream.
112127
# @yieldparam stderr (+FFMPEG::IO+) The standard error stream.
113128
# @yieldparam wait_thr (+Thread+) The child process thread.
114-
# @return [void]
115-
def ffmpeg_popen3(*args, &block)
129+
# @return [Process::Status, Array<IO, Thread>]
130+
def ffmpeg_popen3(*args, &)
116131
logger.debug(self) { "ffmpeg -y #{args.join(' ')}" }
117-
Open3.popen3(ffmpeg_binary, '-y', *args) do |stdin, stdout, stderr, wait_thr|
118-
block.call(stdin, FFMPEG::IO.new(stdout), FFMPEG::IO.new(stderr), wait_thr)
119-
rescue StandardError
120-
wait_thr.kill
121-
wait_thr.join
122-
raise
123-
end
132+
FFMPEG::IO.popen3(ffmpeg_binary, '-y', *args, &)
124133
end
125134

126135
# Execute a ffmpeg command.
@@ -131,12 +140,13 @@ def ffmpeg_popen3(*args, &block)
131140
# @return [Process::Status]
132141
def ffmpeg_execute(*args, reporters: [Reporters::Progress])
133142
ffmpeg_popen3(*args) do |_stdin, _stdout, stderr, wait_thr|
134-
stderr.each do |line|
143+
stderr.each(chomp: true) do |line|
135144
next unless block_given?
136145

137146
reporter = reporters.find { |r| r.match?(line) }
138147
reporter ||= Reporters::Output
139148
report = reporter.new(line)
149+
140150
yield report
141151
end
142152

@@ -169,35 +179,28 @@ def ffprobe_binary=(path)
169179

170180
# Safely captures the standard output and the standard error of the ffmpeg command.
171181
#
172-
# @return [Array] The standard output, the standard error, and the process status.
182+
# @param args [Array<String>] The arguments to pass to ffprobe.
183+
# @return [Array<String, Process::Status>] The standard output, the standard error, and the process status.
173184
# @raise [Errno::ENOENT] If the ffprobe binary cannot be found.
174185
def ffprobe_capture3(*args)
175186
logger.debug(self) { "ffprobe -y #{args.join(' ')}" }
176-
stdout, stderr, status = Open3.capture3(ffprobe_binary, '-y', *args)
177-
FFMPEG::IO.encode!(stdout)
178-
FFMPEG::IO.encode!(stderr)
179-
[stdout, stderr, status]
187+
FFMPEG::IO.capture3(ffprobe_binary, '-y', *args)
180188
end
181189

182190
# Starts a new ffprobe process with the given arguments.
183191
# Yields the standard input, the standard output
184192
# and the standard error streams, as well as the child process
185193
# to the specified block.
186194
#
195+
# @param args [Array<String>] The arguments to pass to ffprobe.
187196
# @yieldparam stdin (+IO+) The standard input stream.
188197
# @yieldparam stdout (+FFMPEG::IO+) The standard output stream.
189198
# @yieldparam stderr (+FFMPEG::IO+) The standard error stream.
190-
# @return [void]
199+
# @return [Process::Status, Array<IO, Thread>]
191200
# @raise [Errno::ENOENT] If the ffprobe binary cannot be found.
192-
def ffprobe_popen3(*args, &block)
201+
def ffprobe_popen3(*args, &)
193202
logger.debug(self) { "ffprobe -y #{args.join(' ')}" }
194-
Open3.popen3(ffprobe_binary, '-y', *args) do |stdin, stdout, stderr, wait_thr|
195-
block.call(stdin, FFMPEG::IO.new(stdout), FFMPEG::IO.new(stderr), wait_thr)
196-
rescue StandardError
197-
wait_thr.kill
198-
wait_thr.join
199-
raise
200-
end
203+
FFMPEG::IO.popen3(ffprobe_binary, '-y', *args, &)
201204
end
202205

203206
# Cross-platform way of finding an executable in the $PATH.

lib/ffmpeg/io.rb

+51-70
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,75 @@
11
# frozen_string_literal: true
22

33
require 'English'
4-
require 'timeout'
5-
6-
require_relative 'timeout'
4+
require 'open3'
75

86
module FFMPEG
9-
# The IO class is a simple wrapper around IO objects that adds a timeout
10-
# to all read operations and fixes encoding issues.
11-
class IO
12-
attr_accessor :timeout
7+
# The IO module provides low-level methods for opening, capturing, and encoding
8+
# IO streams produced by the ffmpeg and ffprobe binaries.
9+
module IO
10+
class << self
11+
attr_writer :timeout, :encoding
1312

14-
def self.encode!(chunk)
15-
chunk[/test/]
16-
rescue ArgumentError
17-
chunk.encode!(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '?')
18-
end
13+
def timeout
14+
return @timeout if defined?(@timeout)
15+
16+
@timeout = 30
17+
end
18+
19+
def encoding
20+
@encoding ||= Encoding::UTF_8
21+
end
22+
23+
def encode!(string)
24+
string.encode!(encoding, invalid: :replace, undef: :replace)
25+
end
1926

20-
def initialize(target)
21-
@target = target
22-
@timeout = FFMPEG.io_timeout
27+
def extend!(io)
28+
io.timeout = timeout
29+
io.set_encoding(encoding, invalid: :replace, undef: :replace)
30+
io.extend(FFMPEG::IO)
31+
end
32+
33+
def capture3(*cmd)
34+
*io, status = Open3.capture3(*cmd)
35+
io.each(&method(:encode!))
36+
[*io, status]
37+
end
38+
39+
def popen3(*cmd, &block)
40+
if block_given?
41+
Open3.popen3(*cmd) do |*io, wait_thr|
42+
io = io.map(&method(:extend!))
43+
block.call(*io, wait_thr)
44+
rescue StandardError
45+
wait_thr.kill
46+
wait_thr.join
47+
raise
48+
end
49+
else
50+
*io, wait_thr = Open3.popen3(*cmd)
51+
io = io.map(&method(:extend!))
52+
[*io, wait_thr]
53+
end
54+
end
2355
end
2456

25-
def each(&block)
26-
timer = timeout.nil? ? nil : Timeout.start(timeout)
57+
def each(chomp: false, &block)
2758
buffer = String.new
2859

2960
until eof?
3061
char = getc
3162
case char
32-
when "\n", "\r"
33-
timer&.tick
34-
timer&.pause
35-
block.call(buffer)
36-
timer&.resume
63+
when "\r", "\n"
64+
buffer << ($ORS || "\n") unless chomp
65+
block.call(buffer) unless buffer.empty?
3766
buffer = String.new
3867
else
3968
buffer << char
4069
end
4170
end
4271

4372
block.call(buffer) unless buffer.empty?
44-
ensure
45-
timer&.cancel
46-
end
47-
48-
%i[
49-
getc
50-
gets
51-
readchar
52-
readline
53-
].each do |symbol|
54-
define_method(symbol) do |*args|
55-
data = @target.send(symbol, *args)
56-
self.class.encode!(data) unless data.nil?
57-
data
58-
end
59-
end
60-
61-
%i[
62-
each_char
63-
each_line
64-
].each do |symbol|
65-
define_method(symbol) do |*args, &block|
66-
timer = timeout.nil? ? nil : Timeout.start(timeout)
67-
@target.send(symbol, *args) do |data|
68-
timer&.tick
69-
timer&.pause
70-
block.call(self.class.encode!(data))
71-
timer&.resume
72-
end
73-
ensure
74-
timer&.cancel
75-
end
76-
end
77-
78-
def readlines(*args)
79-
lines = []
80-
each(*args) { |line| lines << line }
81-
lines
82-
end
83-
84-
private
85-
86-
def respond_to_missing?(symbol, include_private = false)
87-
@target.respond_to?(symbol, include_private)
88-
end
89-
90-
def method_missing(symbol, *args)
91-
@target.send(symbol, *args)
9273
end
9374
end
9475
end

lib/ffmpeg/media.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class LoadError < FFMPEG::Error; end
5959
:format_name, :format_long_name,
6060
:start_time, :bit_rate, :duration
6161

62-
# @param path [String] The local path or remote URL to a multimedia file.
62+
# @param path [String, Pathname, URI] The local path or remote URL to a multimedia file.
6363
# @param ffprobe_args [Array<String>] Additional arguments to pass to ffprobe.
6464
# @param load [Boolean] Whether to load the metadata immediately.
6565
# @param autoload [Boolean] Whether to autoload the metadata when accessing attributes.

0 commit comments

Comments
 (0)