Skip to content

Commit 33352b1

Browse files
committed
feat: add new remuxer to normalize media within milliseconds
1 parent f50e9f0 commit 33352b1

13 files changed

Lines changed: 310 additions & 11 deletions

File tree

lib/ffmpeg.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
require_relative 'ffmpeg/presets/dash/h264'
3030
require_relative 'ffmpeg/presets/h264'
3131
require_relative 'ffmpeg/raw_command_args'
32+
require_relative 'ffmpeg/remuxer'
3233
require_relative 'ffmpeg/reporters/output'
3334
require_relative 'ffmpeg/reporters/progress'
3435
require_relative 'ffmpeg/reporters/silence'
@@ -270,6 +271,40 @@ def ffprobe_popen3(*args, &)
270271
FFMPEG::IO.popen3(ffprobe_binary, *args, &)
271272
end
272273

274+
# Get the path to the exiftool binary.
275+
# Returns nil if exiftool is not found in the PATH.
276+
#
277+
# @return [String, nil]
278+
def exiftool_binary
279+
return @exiftool_binary if defined?(@exiftool_binary)
280+
281+
@exiftool_binary = which('exiftool')
282+
rescue Errno::ENOENT
283+
@exiftool_binary = nil
284+
end
285+
286+
# Set the path to the exiftool binary.
287+
#
288+
# @param path [String]
289+
# @return [String]
290+
# @raise [Errno::ENOENT] If the exiftool binary is not an executable.
291+
def exiftool_binary=(path)
292+
if path.is_a?(String) && !File.executable?(path)
293+
raise Errno::ENOENT, "The exiftool binary, '#{path}', is not executable"
294+
end
295+
296+
@exiftool_binary = path
297+
end
298+
299+
# Safely captures the standard output and the standard error of the exiftool command.
300+
#
301+
# @param args [Array<String>] The arguments to pass to exiftool.
302+
# @return [Array<String, Process::Status>] The standard output, the standard error, and the process status.
303+
def exiftool_capture3(*args)
304+
logger.debug(self) { "exiftool #{Shellwords.join(args)}" }
305+
FFMPEG::IO.capture3(exiftool_binary, *args)
306+
end
307+
273308
# Cross-platform way of finding an executable in the $PATH.
274309
# See http://stackoverflow.com/questions/2108727/which-in-ruby-checking-if-program-exists-in-path-from-ruby
275310
#

lib/ffmpeg/command_args.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class << self
2727
#
2828
# @param media [FFMPEG::Media] The media to transcode.
2929
# @param context [Hash, nil] Additional context for composing the arguments.
30-
# # @return [FFMPEG::CommandArgs] The new FFMPEG::CommandArgs object.
30+
# @return [FFMPEG::CommandArgs] The new FFMPEG::CommandArgs object.
3131
def compose(media, context: nil, &block)
3232
new(media, context:).tap do |args|
3333
args.instance_exec(&block) if block_given?
@@ -66,7 +66,7 @@ def video_bit_rate(target_value, **kwargs)
6666
super(adjusted_video_bit_rate(target_value), **kwargs)
6767
end
6868

69-
# Sets the audio bit rate to the minimum of the current audio bit rate and the target value.
69+
# Sets the minimum video bit rate to the minimum of the current video bit rate and the target value.
7070
# The target value can be an Integer or a String (e.g.: 128k or 1M).
7171
#
7272
# @param target_value [Integer, String] The target bit rate.
@@ -77,7 +77,7 @@ def min_video_bit_rate(target_value)
7777
super(adjusted_video_bit_rate(target_value))
7878
end
7979

80-
# Sets the audio bit rate to the minimum of the current audio bit rate and the target value.
80+
# Sets the maximum video bit rate to the minimum of the current video bit rate and the target value.
8181
# The target value can be an Integer or a String (e.g.: 128k or 1M).
8282
#
8383
# @param target_value [Integer, String] The target bit rate.

lib/ffmpeg/filter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class << self
1818
# @param filters [Array<Filter>] The filters to join.
1919
# @return [String] The filter chain.
2020
def join(*filters)
21-
filters.compact.map(&:to_s).join(',')
21+
filters.compact.join(',')
2222
end
2323
end
2424

lib/ffmpeg/io.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,32 +10,62 @@ module IO
1010
class << self
1111
attr_writer :timeout, :encoding
1212

13+
# Returns the I/O timeout in seconds. Defaults to 30.
14+
#
15+
# @return [Integer]
1316
def timeout
1417
return @timeout if defined?(@timeout)
1518

1619
@timeout = 30
1720
end
1821

22+
# Returns the I/O encoding. Defaults to UTF-8.
23+
#
24+
# @return [Encoding]
1925
def encoding
2026
@encoding ||= Encoding::UTF_8
2127
end
2228

29+
# Encodes the string in-place using the configured encoding,
30+
# replacing invalid and undefined characters.
31+
#
32+
# @param string [String] The string to encode.
33+
# @return [String]
2334
def encode!(string)
2435
string.encode!(encoding, invalid: :replace, undef: :replace)
2536
end
2637

38+
# Extends the given IO object with the configured timeout, encoding,
39+
# and the FFMPEG::IO module.
40+
#
41+
# @param io [IO] The IO object to extend.
42+
# @return [IO]
2743
def extend!(io)
2844
io.timeout = timeout
2945
io.set_encoding(encoding, invalid: :replace, undef: :replace)
3046
io.extend(FFMPEG::IO)
3147
end
3248

49+
# Runs the given command and captures stdout, stderr, and the process status.
50+
# Encodes the output using the configured encoding.
51+
#
52+
# @param cmd [Array<String>] The command to run.
53+
# @return [Array<String, Process::Status>] stdout, stderr, and the process status.
3354
def capture3(*cmd)
3455
*io, status = Open3.capture3(*cmd)
3556
io.each(&method(:encode!))
3657
[*io, status]
3758
end
3859

60+
# Starts the given command and yields or returns stdin, stdout, stderr, and the wait thread.
61+
# Each IO stream is extended with the configured timeout and encoding.
62+
#
63+
# @param cmd [Array<String>] The command to run.
64+
# @yieldparam stdin [IO]
65+
# @yieldparam stdout [FFMPEG::IO]
66+
# @yieldparam stderr [FFMPEG::IO]
67+
# @yieldparam wait_thr [Thread]
68+
# @return [Process::Status, Array<IO, Thread>]
3969
def popen3(*cmd, &block)
4070
if block_given?
4171
Open3.popen3(*cmd) do |*io, wait_thr|
@@ -54,6 +84,10 @@ def popen3(*cmd, &block)
5484
end
5585
end
5686

87+
# Iterates over each line of the IO stream, yielding each line to the block.
88+
#
89+
# @param chomp [Boolean] Whether to include the line separator in each yielded line.
90+
# @yieldparam line [String] Each line from the stream.
5791
def each(chomp: false, &block)
5892
# We need to run this loop in a separate thread to avoid
5993
# errors with exit signals being sent to the main thread.

lib/ffmpeg/media.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,30 @@ def initialize(path, *ffprobe_args, load: true, autoload: true)
8080
load! if load
8181
end
8282

83+
# Remuxes the media file to the given output path via stream copy.
84+
# If the initial stream copy fails and the video codec supports Annex B
85+
# extraction, it falls back to extracting raw streams and re-muxing with
86+
# a corrected frame rate.
87+
#
88+
# @param output_path [String, Pathname] The output path for the remuxed file.
89+
# @param timeout [Integer, nil] Timeout in seconds for each ffmpeg command.
90+
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
91+
# @return [FFMPEG::Transcoder::Status]
92+
def remux(output_path, timeout: nil, &block)
93+
Remuxer.new(timeout:).process(self, output_path, &block)
94+
end
95+
96+
# Remuxes the media file to the given output path via stream copy,
97+
# raising an error if the remux fails.
98+
#
99+
# @param output_path [String, Pathname] The output path for the remuxed file.
100+
# @param timeout [Integer, nil] Timeout in seconds for each ffmpeg command.
101+
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
102+
# @return [FFMPEG::Transcoder::Status]
103+
def remux!(output_path, timeout: nil, &block)
104+
remux(output_path, timeout:, &block).assert!
105+
end
106+
83107
# Load the metadata of the multimedia file.
84108
#
85109
# @return [Boolean]

lib/ffmpeg/raw_command_args.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ def bitstream_filters(*filters, stream_id: nil, stream_index: nil)
349349
# @param filters [Array<FFMPEG::Filter, String>] The filters to add.
350350
# @return [self]
351351
def filter_complex(*filters)
352-
arg('filter_complex', filters.compact.map(&:to_s).join(';'))
352+
arg('filter_complex', filters.compact.join(';'))
353353

354354
self
355355
end

lib/ffmpeg/remuxer.rb

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# frozen_string_literal: true
2+
3+
require 'fileutils'
4+
require 'tmpdir'
5+
6+
require_relative 'media'
7+
8+
module FFMPEG
9+
# The Remuxer class is responsible for remuxing multimedia files via stream copy.
10+
# It attempts a direct stream copy first, and if that fails (e.g. due to corrupted
11+
# timestamps), it falls back to extracting raw Annex B streams and re-muxing them
12+
# with a corrected frame rate.
13+
#
14+
# @example
15+
# remuxer = FFMPEG::Remuxer.new
16+
# status = remuxer.process('input.mp4', 'output.mp4')
17+
# status.success? # => true
18+
class Remuxer
19+
ANNEXB_CODEC_NAMES = %w[h264 hevc].freeze
20+
21+
# @param name [String, nil] An optional name for the remuxer.
22+
# @param metadata [Hash, nil] Optional metadata to associate with the remuxer.
23+
# @param checks [Array<Symbol, Proc>] Checks to run on the output to determine success.
24+
# @param timeout [Integer, nil] Timeout in seconds for each ffmpeg command.
25+
def initialize(name: nil, metadata: nil, checks: %i[exist?], timeout: nil)
26+
@name = name
27+
@metadata = metadata
28+
@checks = checks
29+
@timeout = timeout
30+
end
31+
32+
class << self
33+
# Returns true if the media has a video codec that supports lossless
34+
# Annex B bitstream extraction (H.264 or H.265).
35+
#
36+
# @param media [FFMPEG::Media]
37+
# @return [Boolean]
38+
def annexb?(media)
39+
media.video? && ANNEXB_CODEC_NAMES.include?(media.video_codec_name)
40+
end
41+
end
42+
43+
# Remuxes the media file to the given output path via stream copy.
44+
# If the initial stream copy fails and the video codec supports Annex B
45+
# extraction, it falls back to extracting raw streams and re-muxing with
46+
# a corrected frame rate.
47+
#
48+
# @param media [String, Pathname, URI, FFMPEG::Media] The media file to remux.
49+
# @param output_path [String, Pathname] The output path for the remuxed file.
50+
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
51+
# @return [FFMPEG::Transcoder::Status]
52+
def process(media, output_path, &)
53+
media = Media.new(media, load: false) unless media.is_a?(Media)
54+
55+
status = ffmpeg_copy(media, output_path, &)
56+
return status if status.success?
57+
return status unless self.class.annexb?(media)
58+
59+
Dir.mktmpdir do |tmpdir|
60+
annexb_extname = media.video_codec_name == 'hevc' ? '.h265' : '.h264'
61+
annexb_path = File.join(tmpdir, "remux#{annexb_extname}")
62+
annexb_filter = annexb_filter(media)
63+
annexb_status = ffmpeg_copy(media, '-map', '0:v:0', *annexb_filter, annexb_path, &)
64+
return annexb_status unless annexb_status.success?
65+
66+
mka_path = File.join(tmpdir, 'remux.mka')
67+
mka_status = ffmpeg_copy(media, '-vn', mka_path, &)
68+
return mka_status unless mka_status.success?
69+
70+
video = annexb_status.media.first
71+
audio = mka_status.media.first
72+
frame_rate = detect_frame_rate(video, audio)
73+
74+
status = ffmpeg_copy(
75+
[video, audio, media],
76+
'-map', '0:v',
77+
'-map', '1:a',
78+
'-map_metadata', '2',
79+
output_path,
80+
inargs: %W[-r #{frame_rate}],
81+
&
82+
)
83+
return status unless status.success?
84+
return status unless FFMPEG.exiftool_binary
85+
86+
FFMPEG.exiftool_capture3(
87+
'-overwrite_original',
88+
"-rotation=#{media.rotation}",
89+
output_path
90+
).tap do |_, stderr, exiftool_status|
91+
next if exiftool_status.success?
92+
93+
status.warn!("ExifTool exited with non-zero status: #{exiftool_status.exitstatus}\n#{stderr.strip}")
94+
end
95+
96+
status
97+
end
98+
end
99+
100+
# Remuxes the media file to the given output path via stream copy,
101+
# raising an error if the remux fails.
102+
#
103+
# @param media [String, Pathname, URI, FFMPEG::Media] The media file to remux.
104+
# @param output_path [String, Pathname] The output path for the remuxed file.
105+
# @yield [report] Reports from the ffmpeg command (see FFMPEG::Reporters).
106+
# @return [FFMPEG::Transcoder::Status]
107+
def process!(media, output_path, &)
108+
process(media, output_path, &).assert!
109+
end
110+
111+
protected
112+
113+
def ffmpeg_copy(media, *args, inargs: [], &)
114+
media = [media] unless media.is_a?(Array)
115+
116+
FFMPEG.ffmpeg_execute(
117+
*inargs.map(&:to_s),
118+
*media.map { ['-i', _1.path.to_s] }.flatten,
119+
'-c',
120+
'copy',
121+
*args.map(&:to_s),
122+
timeout: @timeout,
123+
status: Transcoder::Status.new([args.last], checks: @checks),
124+
&
125+
)
126+
end
127+
128+
def annexb_filter(media)
129+
['-bsf:v', "#{media.video_codec_name}_mp4toannexb"]
130+
end
131+
132+
def detect_frame_rate(video, audio)
133+
stdout, = FFMPEG.ffprobe_capture3(
134+
'-v', 'quiet',
135+
'-count_packets',
136+
'-select_streams', 'v:0',
137+
'-show_entries', 'stream=nb_read_packets',
138+
'-of', 'csv=p=0',
139+
video.path
140+
)
141+
frame_count = stdout.strip.to_i
142+
143+
stdout, = FFMPEG.ffprobe_capture3(
144+
'-v', 'quiet',
145+
'-show_entries', 'format=duration',
146+
'-of', 'csv=p=0',
147+
audio.path
148+
)
149+
duration = stdout.strip.to_f
150+
151+
(frame_count.to_f / duration).round
152+
end
153+
end
154+
end

lib/ffmpeg/reporters/output.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,25 @@ module FFMPEG
44
module Reporters
55
# Represents a raw output line from ffmpeg.
66
class Output
7+
# Returns true — raw output lines are always logged.
8+
#
9+
# @return [Boolean]
710
def self.log? = true
11+
12+
# Returns true — this reporter matches every output line.
13+
#
14+
# @param _line [String]
15+
# @return [Boolean]
816
def self.match?(_line) = true
917

1018
attr_reader :output
1119

20+
# @param output [String] The raw output line from ffmpeg.
1221
def initialize(output)
1322
@output = output
1423
end
1524

25+
# @return [String]
1626
def to_s
1727
output
1828
end

lib/ffmpeg/reporters/progress.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@ module FFMPEG
66
module Reporters
77
# Represents the progress of an encoding operation.
88
class Progress < Output
9+
# Returns false — progress lines are not logged.
10+
#
11+
# @return [Boolean]
912
def self.log? = false
1013

14+
# Returns true if the line is a progress line.
15+
#
16+
# @param line [String]
17+
# @return [Boolean]
1118
def self.match?(line)
1219
line.match?(/^\s*(?:size|time|frame)=/)
1320
end

0 commit comments

Comments
 (0)