Skip to content

Commit bd9db05

Browse files
committedSep 23, 2024·
Merge branch 'master' into md/testing
2 parents eaecf91 + da56ad2 commit bd9db05

File tree

5 files changed

+181
-23
lines changed

5 files changed

+181
-23
lines changed
 

‎.github/CODEOWNERS

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# Repo Owners
2-
* @Kyrluckechuck @Vidyard/create-platform
2+
* @Kyrluckechuck @Vidyard/tech-stream

‎lib/ffmpeg/movie.rb

+53-5
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ class Movie
1111
attr_reader :container
1212
attr_reader :error
1313

14+
attr_accessor :has_dynamic_resolution, :requires_pre_encode
15+
1416
UNSUPPORTED_CODEC_PATTERN = /^Unsupported codec with id (\d+) for input stream (\d+)$/
1517

18+
@has_dynamic_resolution = false
19+
@requires_pre_encode = nil
20+
1621
def initialize(paths, analyzeduration = 15000000, probesize=15000000 )
1722
paths = [paths] unless paths.is_a? Array
1823

@@ -98,12 +103,10 @@ def initialize(paths, analyzeduration = 15000000, probesize=15000000 )
98103

99104
@video_stream = "#{video_stream[:codec_name]} (#{video_stream[:profile]}) (#{video_stream[:codec_tag_string]} / #{video_stream[:codec_tag]}), #{colorspace}, #{resolution} [SAR #{sar} DAR #{dar}]"
100105

106+
@rotation = nil
107+
101108
video_stream[:side_data_list].each do |side_data_entry|
102-
@rotation = if side_data_entry.key?(:rotation)
103-
side_data_entry[:rotation].to_i
104-
else
105-
nil
106-
end
109+
@rotation = side_data_entry[:rotation].to_i if side_data_entry.key?(:rotation)
107110
end if video_stream.key?(:side_data_list)
108111
end
109112

@@ -267,6 +270,51 @@ def any_streams_contain_audio?
267270
@any_streams_contain_audio ||= calc_any_streams_contain_audio
268271
end
269272

273+
# Get the max width and max height of all frames in the input movies and check if the resolutions of frames are consistent
274+
def check_frame_resolutions
275+
max_width = width || 0
276+
max_height = height || 0
277+
differing_frame_resolutions = false
278+
last_dimensions = nil
279+
280+
unescaped_paths.each do |path|
281+
local_movie = Movie.new(path)
282+
283+
# set max width and height from the movie metadata first
284+
max_width = [max_width, local_movie.width].max
285+
max_height = [max_height, local_movie.height].max
286+
287+
# Get each keyframe's resolution - this command should be fairly fast, ~30 seconds on a 3 hr video
288+
command = "#{ffprobe_command} -v error -select_streams v:0 -show_entries frame=width,height -of csv=p=0 -skip_frame nokey #{Shellwords.escape(local_movie.path)}" # -skip_frame nokey speeds up processing significantly, only evaluating key frames which seems to be sufficient for frame resolution checks
289+
FFMPEG.logger.info("Running check for varying resolution...\n#{command}\n")
290+
291+
Open3.popen3(command) do |_stdin, stdout, _stderr, _wait_thr|
292+
stdout.each_line do |line|
293+
frame_dimensions = line.split(',').first(2).map(&:to_i) # get the first two values as ffmpeg can sometimes provide an extra comma
294+
frame_width, frame_height = frame_dimensions
295+
296+
next if frame_width.nil? || frame_height.nil?
297+
298+
# Update max width and max height
299+
max_width = [max_width, frame_width].max
300+
max_height = [max_height, frame_height].max
301+
302+
# Check if the current frame resolution differs from the last frame
303+
if last_dimensions && frame_dimensions != last_dimensions
304+
differing_frame_resolutions = true
305+
end
306+
307+
last_dimensions = frame_dimensions
308+
end
309+
end
310+
end
311+
312+
@has_dynamic_resolution = differing_frame_resolutions
313+
314+
# Return the max width, max height, and whether differing resolutions were found
315+
[max_width, max_height, differing_frame_resolutions]
316+
end
317+
270318
protected
271319

272320
def calc_any_streams_contain_audio

‎lib/ffmpeg/transcoder.rb

+24-15
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,22 @@ def self.timeout
1818
@@timeout
1919
end
2020

21+
def requires_pre_encode
22+
# Stitches and trims with dynamic resolution require pre-encoding
23+
@movie.requires_pre_encode ||= @movie.paths.size > 1 || (@movie.has_dynamic_resolution && @transcoder_options[:permit_dynamic_resolution_pre_encode])
24+
return @movie.requires_pre_encode
25+
end
26+
2127
def initialize(movie, output_file, options = EncodingOptions.new, transcoder_options = {}, transcoder_prefix_options = {})
2228
@movie = movie
2329
@output_file = output_file
30+
@transcoder_options = transcoder_options
2431

25-
if @movie.paths.size > 1
32+
# If the movie has frames with varying resolutions, we need to pre-encode the movie for trims
33+
# This is because ffmpeg can't reliably handle inputs that contain frames with differing resolutions particularly for trimming with `filter_complex`
34+
@movie.check_frame_resolutions if @transcoder_options[:permit_dynamic_resolution_pre_encode]
35+
36+
if requires_pre_encode
2637
@movie.paths.each do |path|
2738
# Make the interim path folder if it doesn't exist
2839
dirname = "#{File.dirname(path)}/interim"
@@ -48,7 +59,6 @@ def initialize(movie, output_file, options = EncodingOptions.new, transcoder_opt
4859
raise ArgumentError, "Unknown options format '#{options.class}', should be either EncodingOptions, Hash or String."
4960
end
5061

51-
@transcoder_options = transcoder_options
5262
@transcoder_prefix_options = transcoder_prefix_options
5363
@errors = []
5464

@@ -76,9 +86,9 @@ def encoded
7686
end
7787

7888
private
89+
7990
def pre_encode_if_necessary
80-
# Don't pre-encode single inputs since it doesn't need any size conversion
81-
return if @movie.interim_paths.size <= 1
91+
return unless requires_pre_encode
8292

8393
# Set a minimum frame rate
8494
output_frame_rate = [@raw_options[:frame_rate] || @movie.frame_rate, 30].max
@@ -95,7 +105,13 @@ def pre_encode_if_necessary
95105
@movie.unescaped_paths.each_with_index do |path, index|
96106
audio_map = determine_audio_for_pre_encode(path)
97107

98-
command = "#{@movie.ffmpeg_command} -y -i #{Shellwords.escape(path)} -movflags faststart #{pre_encode_options} -r #{output_frame_rate} -filter_complex \"[0:v]scale=#{max_width}:#{max_height}:force_original_aspect_ratio=decrease,pad=#{max_width}:#{max_height}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[Scaled]\" -map \"[Scaled]\" #{audio_map} #{@movie.interim_paths[index]}"
108+
# Only re-scale the video if there are multiple inputs. If a single input video contains dynamic resolution it can cause issues with the filter_complex filter
109+
complex_filter = ""
110+
if @movie.paths.size > 1
111+
complex_filter = "-filter_complex \"[0:v]scale=#{max_width}:#{max_height}:force_original_aspect_ratio=decrease,pad=#{max_width}:#{max_height}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[Scaled]\" -map \"[Scaled]\" #{audio_map}"
112+
end
113+
114+
command = "#{@movie.ffmpeg_command} -y -i #{Shellwords.escape(path)} -movflags faststart #{pre_encode_options} -r #{output_frame_rate} #{complex_filter} #{@movie.interim_paths[index]}"
99115
FFMPEG.logger.info("Running pre-encoding...\n#{command}\n")
100116
output = ""
101117

@@ -257,19 +273,12 @@ def convert_prefix_options_to_string(transcoder_prefix_options)
257273
end
258274

259275
def calculate_interim_max_dimensions
260-
max_width = @movie.width
261-
max_height = @movie.height
262-
# Find best highest resolution
263-
@movie.unescaped_paths.each do |path|
264-
local_movie = Movie.new(path)
265-
266-
# If the local resolution is larger than the current highest
267-
max_width = [local_movie.width, max_width].max
268-
max_height = [local_movie.height, max_height].max
269-
end
276+
# Get max width and max height of all inputs from each frame
277+
max_width, max_height = @movie.check_frame_resolutions
270278

271279
converted_width = (max_height * FIXED_LOWER_TO_UPPER_RATIO).ceil()
272280
converted_height = (max_width * FIXED_UPPER_TO_LOWER_RATIO).ceil()
281+
273282
# Convert to always be a 16:9 ratio
274283
# If the converted width will not be a decrease in resolution, upscale the width
275284
if converted_width >= max_width

‎spec/ffmpeg/movie_spec.rb

+27
Original file line numberDiff line numberDiff line change
@@ -545,5 +545,32 @@ module FFMPEG # rubocop:todo Metrics/ModuleLength
545545
movie.screenshot("#{tmp_path}/awesome.jpg", {seek_time: 2, dimensions: "640x480"}, {preserve_aspect_ratio: :width})
546546
end
547547
end
548+
549+
describe "#check_frame_resolutions" do
550+
let(:movie) { Movie.new("#{fixture_path}/movies/awesome movie.mov") }
551+
let(:movie_with_multiple_dimension_inputs) { Movie.new(["#{fixture_path}/movies/awesome_widescreen.mov", "#{fixture_path}/movies/sideways_movie.mov"]) }
552+
553+
it "handles empty frame resolution lines gracefully" do
554+
allow(Open3).to receive(:popen3).and_yield(nil, "640,480\n\n", nil, nil)
555+
expect(movie.check_frame_resolutions).to eq([640, 480, false])
556+
end
557+
558+
describe 'returns the correct max width and height' do
559+
it "when resolutions are consistent" do
560+
allow(Open3).to receive(:popen3).and_yield(nil, "640,480\n640,480\n", nil, nil)
561+
expect(movie.check_frame_resolutions).to eq([640, 480, false])
562+
end
563+
564+
it "when resolutions are inconsistent" do
565+
allow(Open3).to receive(:popen3).and_yield(nil, "640,480\n1920,300\n", nil, nil)
566+
expect(movie.check_frame_resolutions).to eq([1920, 480, true])
567+
end
568+
569+
it "when multiple inputs are provided" do
570+
allow(Open3).to receive(:popen3).and_yield(nil, "640,480\n", nil, nil).and_yield(nil, "1920,300\n", nil, nil)
571+
expect(movie_with_multiple_dimension_inputs.check_frame_resolutions).to eq([1920, 480, true])
572+
end
573+
end
574+
end
548575
end
549576
end

‎spec/ffmpeg/transcoder_spec.rb

+76-2
Original file line numberDiff line numberDiff line change
@@ -323,34 +323,108 @@ module FFMPEG
323323
describe "pre_encode_if_necessary" do
324324
let(:output_path) { "#{tmp_path}/pre_encode_out.mp4" }
325325

326-
it 'returns early if only a single video file' do
326+
it 'returns early if only a single video file and no dynamic resolution' do
327327
transcoder = Transcoder.new(movie, output_path, EncodingOptions.new)
328+
allow(movie).to receive(:has_dynamic_resolution).and_return(false)
328329
expect(movie).not_to receive(:height)
329330
transcoder.send(:pre_encode_if_necessary)
330331
end
331332

333+
it 'requires pre-encoding when movie has a single path and dynamic resolution is permitted' do
334+
transcoder = Transcoder.new(movie, output_path, EncodingOptions.new, { permit_dynamic_resolution_pre_encode: true })
335+
allow(movie).to receive(:has_dynamic_resolution).and_return(true)
336+
expect(transcoder.requires_pre_encode).to be true
337+
end
338+
339+
it 'does not require pre-encoding when movie has a single path and dynamic resolution is not permitted' do
340+
transcoder = Transcoder.new(movie, output_path, EncodingOptions.new, { permit_dynamic_resolution_pre_encode: false })
341+
allow(movie).to receive(:has_dynamic_resolution).and_return(true)
342+
expect(transcoder.requires_pre_encode).to be false
343+
end
344+
332345
describe 'creates interim inputs with scaling correctly applied based on input files' do
333346
it 'with audio' do
334347
transcoder = Transcoder.new(movie_with_multiple_dimension_inputs, output_path, EncodingOptions.new)
348+
349+
expect(Open3).to receive(:popen3).exactly(2).times # prevent ffprobe calls from being evaluated (check_frame_resolutions)
335350
expect(Open3).to receive(:popen3).twice.with match(/.*\[0\:v\]scale\=854\:480.*pad\=854\:480\:\(ow\-iw\)\/2\:\(oh\-ih\)\/2\:color\=black.*\-map \"0\:a\".*/)
336-
transcoder.send(:pre_encode_if_necessary)
337351

352+
transcoder.send(:pre_encode_if_necessary)
338353
end
339354

340355
it 'with silent audio' do
341356
transcoder = Transcoder.new(movie_with_multiple_dimension_inputs_with_partial_audio, output_path, EncodingOptions.new)
357+
358+
expect(Open3).to receive(:popen3).exactly(2).times # prevent ffprobe calls from being evaluated (check_frame_resolutions)
342359
# Match silent audio fill
343360
expect(Open3).to receive(:popen3).once.with match(/.*\[0\:v\]scale\=960\:540.*pad\=960\:540\:\(ow\-iw\)\/2\:\(oh\-ih\)\/2\:color\=black.*\-map \"\[a\]\".*/)
344361
# Retain "real" audio
345362
expect(Open3).to receive(:popen3).once.with match(/.*\[0\:v\]scale\=960\:540.*pad\=960\:540\:\(ow\-iw\)\/2\:\(oh\-ih\)\/2\:color\=black.*\-map \"0\:a\".*/)
363+
346364
transcoder.send(:pre_encode_if_necessary)
347365
end
348366

349367
it 'without any audio' do
350368
transcoder = Transcoder.new(movie_with_multiple_dimension_inputs_with_no_audio, output_path, EncodingOptions.new)
369+
370+
expect(Open3).to receive(:popen3).exactly(2).times # prevent ffprobe calls from being evaluated (check_frame_resolutions)
351371
expect(Open3).to receive(:popen3).twice.with match(/.*\[0\:v\]scale\=960\:540.*pad\=960\:540\:\(ow\-iw\)\/2\:\(oh\-ih\)\/2\:color\=black.*((?!\-map \"0\:a\")(?!\-map \"\[a\]\")).*/)
372+
373+
transcoder.send(:pre_encode_if_necessary)
374+
end
375+
end
376+
377+
describe "correctly applies the complex filter" do
378+
it "when more than one input is provided will apply the complex filter" do
379+
transcoder = Transcoder.new(movie_with_two_inputs, output_path, EncodingOptions.new)
380+
381+
expect(Open3).to receive(:popen3).exactly(2).times # prevent ffprobe calls from being evaluated (check_frame_resolutions)
382+
expect(Open3).to receive(:popen3).twice.with match(/-filter_complex/)
383+
352384
transcoder.send(:pre_encode_if_necessary)
353385
end
386+
387+
it "when only one input is provided does not apply the complex filter" do
388+
transcoder_options = { permit_dynamic_resolution_pre_encode: true }
389+
transcoder = Transcoder.new(movie, output_path, EncodingOptions.new, transcoder_options)
390+
allow(movie).to receive(:has_dynamic_resolution).and_return(true)
391+
392+
expect(Open3).not_to receive(:popen3).with('-filter_complex')
393+
394+
transcoder.send(:pre_encode_if_necessary)
395+
end
396+
end
397+
end
398+
399+
describe "#requires_pre_encode" do
400+
let(:output_path) { "#{tmp_path}/output.mp4" }
401+
402+
it 'requires pre-encoding when movie has multiple paths' do
403+
transcoder = Transcoder.new(movie_with_two_inputs, output_path, EncodingOptions.new)
404+
expect(transcoder.requires_pre_encode).to be true
405+
end
406+
407+
it 'requires pre-encoding when movie has a single path and dynamic resolution is permitted' do
408+
movie = Movie.new("#{fixture_path}/movies/awesome movie.mov")
409+
transcoder_options = { permit_dynamic_resolution_pre_encode: true }
410+
transcoder = Transcoder.new(movie, output_path, EncodingOptions.new, transcoder_options)
411+
allow(movie).to receive(:has_dynamic_resolution).and_return(true)
412+
expect(transcoder.requires_pre_encode).to be true
413+
end
414+
415+
it 'does not require pre-encoding when movie has a single path and dynamic resolution is not permitted' do
416+
movie = Movie.new("#{fixture_path}/movies/awesome movie.mov")
417+
transcoder_options = { permit_dynamic_resolution_pre_encode: false }
418+
transcoder = Transcoder.new(movie, output_path, EncodingOptions.new, transcoder_options)
419+
allow(movie).to receive(:has_dynamic_resolution).and_return(true)
420+
expect(transcoder.requires_pre_encode).to be false
421+
end
422+
423+
it 'does not require pre-encoding when movie has a single path without dynamic resolution' do
424+
movie = Movie.new("#{fixture_path}/movies/awesome movie.mov")
425+
transcoder = Transcoder.new(movie, output_path, EncodingOptions.new)
426+
allow(movie).to receive(:has_dynamic_resolution).and_return(false)
427+
expect(transcoder.requires_pre_encode).to be false
354428
end
355429
end
356430
end

0 commit comments

Comments
 (0)
Please sign in to comment.