Last active
November 9, 2022 22:35
-
-
Save lisamelton/7c9da839d69ca9a90d1b000e5762b3e3 to your computer and use it in GitHub Desktop.
Ruby script for Windows and Linux to transcode or copy essential media tracks into a smaller, more portable format while remaining high enough quality to be mistaken for the original.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# | |
# special-transcode.rb | |
# | |
# Copyright (c) 2019-2022 Don Melton | |
# | |
require 'English' | |
require 'fileutils' | |
require 'json' | |
require 'optparse' | |
module Transcoding | |
class UsageError < RuntimeError | |
end | |
class Command | |
def about | |
<<-HERE | |
special-transcode.rb 0.0.02022101201 | |
Copyright (c) 2019-2022 Don Melton | |
HERE | |
end | |
def usage | |
<<-HERE | |
Transcode or copy essential media tracks into a smaller, more portable format | |
while remaining high enough quality to be mistaken for the original. | |
Usage: #{File.basename($PROGRAM_NAME)} [OPTION]... [FILE]... | |
Creates a Matroska `.mkv` format file in the current working directory | |
with video in 10-bit HEVC format and audio in multichannel AAC format. | |
Forced subtitles are automatically included along with one other subtitle, | |
the SDH version being preferred. | |
Options: | |
--position TIME, --duration TIME | |
start transcoding at position and/or limit to duration | |
in seconds[.milliseconds] or [HH:]MM:SS[.m...] format | |
--debug increase diagnostic information and create `.log` file | |
-n, --dry-run don't transcode, just show `ffmpeg` command and exit | |
-p, --preview create incomplete output viewable while transcoding | |
-q, --cq VALUE set constant quality value (default: 27) | |
-b, --bitrate TARGET use average bitrate instead of constant quality | |
--preset NAME apply video encoder preset | |
--no-cuda disable CUDA video decoder and pipeline | |
--no-bframe-refs don't set mode for using B-frames as reference frames | |
--deinterlace reduce interlace artifacts without changing frame rate | |
(applied automatically for some inputs) | |
--blur reduce noise with temporary change in resolution | |
--fdk-vbr MODE set FDK AAC audio encoder variable bitrate (VBR) mode | |
(default: 5, use 0 to disable VBR) | |
--eac3 use Dolby Digital Plus (E-AC-3) instead of AAC audio | |
--language CODE match subtitles using language code in ISO 639-2 format | |
(default: eng) | |
--name STRING match first subtitle track with name containing string | |
when SDH or track without name can't be found | |
(default: english) | |
-h, --help display this help and exit | |
--version output version information and exit | |
Requires `ffprobe`, `ffmpeg`, `mkvextract`, `mkvmerge` and `mkvpropedit`. | |
HERE | |
end | |
def initialize | |
@position = nil | |
@duration = nil | |
@debug = false | |
@dry_run = false | |
@preview = false | |
@cq = '27' | |
@bitrate = nil | |
@preset = nil | |
@cuda = true | |
@bframe_refs = true | |
@deinterlace = false | |
@blur = false | |
@aac_encoder = 'aac' | |
@fdk_vbr_mode = '5' | |
@eac3 = false | |
@language = 'eng' | |
@name = 'english' | |
end | |
def run | |
begin | |
OptionParser.new do |opts| | |
define_options opts | |
opts.on '-h', '--help' do | |
puts usage | |
exit | |
end | |
opts.on '--version' do | |
puts about | |
exit | |
end | |
end.parse! | |
rescue OptionParser::ParseError => e | |
raise UsageError, e | |
end | |
fail UsageError, 'missing argument' if ARGV.empty? | |
configure | |
ARGV.each { |arg| process_input arg } | |
exit | |
rescue UsageError => e | |
Kernel.warn "#{$PROGRAM_NAME}: #{e}" | |
Kernel.warn "Try `#{File.basename($PROGRAM_NAME)} --help` for more information." | |
exit false | |
rescue StandardError => e | |
Kernel.warn "#{$PROGRAM_NAME}: #{e}" | |
exit(-1) | |
rescue SignalException | |
puts | |
exit(-1) | |
end | |
def define_options(opts) | |
opts.on '--position ARG' do |arg| | |
@position = resolve_time(arg) | |
end | |
opts.on '--duration ARG' do |arg| | |
@duration = resolve_time(arg) | |
end | |
opts.on '--debug' do | |
@debug = true | |
end | |
opts.on '-n', '--dry-run' do | |
@dry_run = true | |
end | |
opts.on '-p', '--preview' do | |
@preview = true | |
end | |
opts.on '-q', '--cq ARG', Float do |arg| | |
@cq = [[arg, 0].max, 51].min.to_s.sub(/\.0$/, '') | |
@bitrate = nil | |
end | |
opts.on '-b', '--bitrate ARG', Integer do |arg| | |
@bitrate = arg | |
end | |
opts.on '--preset ARG' do |arg| | |
@preset = case arg | |
when 'slow', 'medium', 'fast', /p[1-7]/ | |
arg | |
else | |
fail UsageError, "invalid preset: #{arg}" | |
end | |
end | |
opts.on '--no-cuda' do | |
@cuda = false | |
end | |
opts.on '--no-bframe-refs' do | |
@bframe_refs = false | |
end | |
opts.on '--deinterlace' do | |
@deinterlace = true | |
end | |
opts.on '--blur' do | |
@blur = true | |
end | |
opts.on '--fdk-vbr ARG', Integer do |arg| | |
@fdk_vbr_mode = [[arg, 0].max, 5].min.to_s | |
@eac3 = false | |
end | |
opts.on '--eac3' do | |
@eac3 = true | |
end | |
opts.on '--language ARG' do |arg| | |
fail UsageError, "invalid subtitle language code: #{arg}" unless arg =~ /^[a-z]{3}$/ | |
@language = arg | |
end | |
opts.on '--name ARG' do |arg| | |
@name = arg | |
end | |
end | |
def resolve_time(arg) | |
time = 0.0 | |
case arg | |
when /^([0-9]+(?:\.[0-9]+)?)$/ | |
time = $1.to_f | |
when /^(?:(?:([0-9][0-9]):)?([0-9][0-9]):)?([0-9][0-9](?:\.[0-9]+)?)$/ | |
time = $3.to_f | |
time = ($2.to_i * 60) + time unless $2.nil? | |
time = ($1.to_i * 60 * 60) + time unless $1.nil? | |
else | |
fail UsageError, "invalid time: #{arg}" | |
end | |
time | |
end | |
def configure | |
Kernel.warn 'Configuring...' | |
encoders = '' | |
IO.popen([ | |
'ffmpeg', | |
'-loglevel', 'quiet', | |
'-encoders' | |
]) do |io| | |
encoders = io.read | |
end | |
fail 'configuring failed' unless $CHILD_STATUS.exitstatus == 0 | |
@aac_encoder = 'libfdk_aac' if encoders =~ /libfdk_aac/ | |
end | |
def process_input(path) | |
seconds = Time.now.tv_sec | |
media_info = scan_media(path) | |
video = nil | |
first_audio = nil | |
audio = nil | |
forced_subtitle = nil | |
sdh_subtitle = nil | |
other_subtitle = nil | |
named_subtitle = nil | |
media_info['streams'].each do |stream| | |
case stream['codec_type'] | |
when 'video' | |
video = stream if video.nil? | |
when 'audio' | |
first_audio = stream if first_audio.nil? | |
audio = stream if audio.nil? and stream['disposition']['default'] == 1 | |
when 'subtitle' | |
next if stream.fetch('tags', {}).fetch('language', '') != @language | |
if stream['disposition']['forced'] == 1 | |
forced_subtitle = stream if forced_subtitle.nil? | |
else | |
title = stream.fetch('tags', {}).fetch('title', '') | |
sdh_subtitle = stream if sdh_subtitle.nil? and title =~ /sdh/i | |
other_subtitle = stream if other_subtitle.nil? and title.empty? | |
named_subtitle = stream if named_subtitle.nil? and title =~ /#{@name}/i | |
end | |
end | |
end | |
fail "video track not found: #{arg}" if video.nil? | |
audio ||= first_audio | |
fail "audio track not found: #{arg}" if audio.nil? | |
other_subtitle ||= named_subtitle | |
time_options = get_time_options(media_info) | |
pix_fmt = video.fetch('pix_fmt', 'yuv420p') | |
if @cuda and pix_fmt == 'yuv420p' | |
decoding_options = [ | |
'-hwaccel', 'cuda', | |
'-hwaccel_output_format', 'cuda' | |
] | |
else | |
decoding_options = video['codec_name'] == 'vc1' ? ['-hwaccel', 'auto'] : [] | |
end | |
Kernel.warn 'Stream mapping:' | |
video_options = get_video_options(video) | |
audio_options = get_audio_options(audio) | |
subtitle_options = get_subtitle_options(forced_subtitle, sdh_subtitle, other_subtitle) | |
output = File.basename(path, '.*') + ".#{@preview ? '_PREVIEW_.mkv' : 'mp4'}" | |
fail "output file already exists: #{output}" if File.exist? output | |
ffmpeg_command = [ | |
'ffmpeg', | |
'-loglevel', (@debug ? 'verbose' : 'error'), | |
'-stats', | |
*time_options, | |
*decoding_options, | |
'-i', path, | |
*video_options, | |
*audio_options, | |
'-sn', | |
*(@preview ? [] : ['-map_chapters', '-1']), | |
'-metadata:g', 'title=', | |
*(@preview ? ['-default_mode', 'passthrough'] : []), | |
output | |
] | |
if @preview or subtitle_options.empty? | |
subtitles = nil | |
else | |
subtitles = File.basename(path) + '.subtitles.mkv' | |
fail "subtitles file already exists: #{subtitles}" if File.exist? subtitles | |
ffmpeg_command += [ | |
'-vn', | |
'-an', | |
*subtitle_options, | |
'-map_chapters', '-1', | |
subtitles | |
] | |
end | |
command_line = escape_command(ffmpeg_command) | |
Kernel.warn 'Command line:' | |
if @dry_run | |
puts command_line | |
return | |
end | |
Kernel.warn command_line | |
mkv_output = File.basename(path, '.*') + '.mkv' | |
unless @preview | |
fail "output file already exists: #{mkv_output}" if File.exist? mkv_output | |
end | |
if media_info['chapters'].empty? | |
chapters = nil | |
else | |
chapters = File.basename(path) + '.chapters.xml' | |
unless @preview | |
fail "chapters file already exists: #{chapters}" if File.exist? chapters | |
end | |
end | |
Kernel.warn 'Transcoding...' | |
fail "transcoding failed: #{output}" unless system( | |
(@debug ? {'FFREPORT' => 'level=40'} : {}), | |
*ffmpeg_command | |
) | |
return if @preview | |
Kernel.warn 'Multiplexing...' | |
hdr_options = [] | |
if pix_fmt != 'yuv420p' | |
hdr_info = '' | |
IO.popen([ | |
'ffprobe', | |
'-loglevel', 'quiet', | |
'-select_streams', 'v:0', | |
'-show_frames', | |
'-read_intervals', '%+#1', | |
'-show_entries', 'frame=side_data_list', | |
'-print_format', 'json', | |
path | |
]) do |io| | |
hdr_info = io.read | |
end | |
fail "scanning media failed: #{path}" unless $CHILD_STATUS.exitstatus == 0 | |
begin | |
hdr_info = JSON.parse(hdr_info) | |
rescue JSON::JSONError | |
fail "HDR information not found: #{path}" | |
end | |
md = nil | |
cll = nil | |
hdr_info['frames'].each do |frame| | |
frame.fetch('side_data_list', []).each do |side_data| | |
if side_data['side_data_type'] == 'Mastering display metadata' | |
md = side_data if md.nil? | |
elsif side_data['side_data_type'] == 'Content light level metadata' | |
cll = side_data if cll.nil? | |
end | |
end | |
end | |
unless md.nil? or cll.nil? | |
hdr_options = [ | |
'--max-content-light', "0:#{cll['max_content']}", | |
'--max-frame-light', "0:#{cll['max_average']}", | |
'--chromaticity-coordinates', "0:#{eval md['red_x'] + '.0'}," + | |
"#{eval md['red_y'] + '.0'}," + | |
"#{eval md['green_x'] + '.0'}," + | |
"#{eval md['green_y'] + '.0'}," + | |
"#{eval md['blue_x'] + '.0'}," + | |
"#{eval md['blue_y'] + '.0'}", | |
'--white-colour-coordinates', "0:#{eval md['white_point_x'] + '.0'}," + | |
"#{eval md['white_point_y'] + '.0'}", | |
'--max-luminance', "0:#{eval md['max_luminance'] + '.0'}", | |
'--min-luminance', "0:#{eval md['min_luminance'] + '.0'}" | |
] | |
end | |
end | |
if chapters.nil? | |
adjustment = nil | |
else | |
fail "chapters file already exists: #{chapters}" if File.exist? chapters | |
content = '' | |
IO.popen(['mkvextract', path, 'chapters']) do |io| | |
index = 0 | |
io.each do |line| | |
if line =~ /<ChapterString>/ | |
index += 1 | |
line.sub!(/(#{$MATCH})[^<]*/, '\1' + "Chapter #{index}") | |
end | |
content += line | |
end | |
end | |
fail "chapter extraction failed: #{path}" unless $CHILD_STATUS.exitstatus == 0 | |
begin | |
chapters_file = File.new(chapters, 'wb') | |
chapters_file.print content | |
chapters_file.close | |
rescue SystemCallError => e | |
raise "writing chapters file failed: #{e}" | |
end | |
if @position.nil? | |
adjustment = nil | |
else | |
adjustment = (([media_info['format']['duration'].to_f - 1.0, @position].min) * -1000).to_i.to_s | |
end | |
end | |
fail "output file already exists: #{mkv_output}" if File.exist? mkv_output | |
fail "multiplexing failed: #{mkv_output}" unless system( | |
'mkvmerge', | |
'--output', mkv_output, | |
*hdr_options, | |
output, | |
*(subtitles.nil? ? [] : ['--no-track-tags', '--no-global-tags', subtitles]), | |
*(adjustment.nil? ? [] : ['--chapter-sync', adjustment]), | |
*(chapters.nil? ? [] : ['--chapters', chapters]) | |
) | |
FileUtils.rm output | |
FileUtils.rm subtitles unless subtitles.nil? | |
FileUtils.rm chapters unless chapters.nil? | |
fail "property editing failed: #{mkv_output}" unless system( | |
'mkvpropedit', | |
'--quiet', | |
mkv_output, | |
'--set', 'muxing-application=', | |
'--set', 'writing-application=' | |
) | |
Kernel.warn "\nElapsed time: #{seconds_to_time(Time.now.tv_sec - seconds)}\n\n" | |
end | |
def scan_media(path) | |
Kernel.warn 'Scanning media...' | |
media_info = '' | |
IO.popen([ | |
'ffprobe', | |
'-loglevel', 'quiet', | |
'-show_streams', | |
'-show_chapters', | |
'-show_format', | |
'-print_format', 'json', | |
path | |
]) do |io| | |
media_info = io.read | |
end | |
fail "scanning media failed: #{path}" unless $CHILD_STATUS.exitstatus == 0 | |
begin | |
media_info = JSON.parse(media_info) | |
rescue JSON::JSONError | |
fail "media information not found: #{path}" | |
end | |
Kernel.warn media_info.inspect if @debug | |
media_info | |
end | |
def escape_command(command) | |
command_line = '' | |
command.each {|item| command_line += "#{escape_string(item)} " } | |
command_line.sub!(/ $/, '') | |
command_line | |
end | |
def escape_string(str) | |
# See: https://github.com/larskanis/shellwords | |
return '""' if str.empty? | |
str = str.dup | |
if RUBY_PLATFORM =~ /mingw/ | |
str.gsub!(/((?:\\)*)"/) { "\\" * ($1.length * 2) + "\\\"" } | |
if str =~ /\s/ | |
str.gsub!(/(\\+)\z/) { "\\" * ($1.length * 2 ) } | |
str = "\"#{str}\"" | |
end | |
else | |
str.gsub!(/([^A-Za-z0-9_\-.,:\/@\n])/, "\\\\\\1") | |
str.gsub!(/\n/, "'\n'") | |
end | |
str | |
end | |
def get_time_options(media_info) | |
duration = media_info['format']['duration'].to_f | |
fail "media duration too short: #{duration}" if duration < 2.0 | |
if @position.nil? | |
position = 0.0 | |
else | |
position = [duration - 1.0, @position].min | |
duration -= position | |
end | |
duration = [duration, [@duration, 0.1].max].min unless @duration.nil? | |
options = [] | |
options += ['-ss', position.to_s.sub(/\.0$/, '')] unless @position.nil? | |
options += ['-t', duration.to_s.sub(/\.0$/, '')] unless @duration.nil? | |
time = seconds_to_time(duration.to_i) | |
milliseconds = duration.to_s.sub(/^[0-9]+(\.[0-9]+)$/, '\1') | |
time += milliseconds unless milliseconds == '.0' | |
Kernel.warn "duration = #{time}" | |
options | |
end | |
def seconds_to_time(seconds) | |
sprintf("%02d:%02d:%02d", seconds / (60 * 60), (seconds / 60) % 60, seconds % 60) | |
end | |
def get_video_options(video) | |
if video['codec_name'] == 'mpeg2video' and video['avg_frame_rate'] == '30000/1001' | |
options = ['-vsync', 'cfr'] | |
else | |
options = [] | |
end | |
pix_fmt = video.fetch('pix_fmt', 'yuv420p') | |
if @cuda and pix_fmt == 'yuv420p' | |
cuda = true | |
scale_filter = 'scale_cuda' | |
yadif_filter = 'yadif_cuda' | |
else | |
cuda = false | |
scale_filter = 'scale' | |
yadif_filter = 'yadif' | |
end | |
width = video['width'].to_i | |
height = video['height'].to_i | |
if @blur | |
filters = ["#{scale_filter}=#{(width / 6) * 5}:#{(height / 6) * 5}", "#{scale_filter}=#{width}:#{height}"] | |
else | |
filters = [] | |
end | |
filters += ['scale_cuda=format=p010le'] if cuda | |
filters += [yadif_filter] if @deinterlace or video.fetch('field_order', 'progressive') != 'progressive' | |
if filters.empty? | |
options += ['-map', "0:#{video['index']}"] | |
else | |
options += [ | |
'-filter_complex', "[0:#{video['index']}]#{filters.join(',')}[v]", | |
'-map', '[v]', | |
] | |
end | |
color_primaries = video['color_primaries'] | |
color_trc = video['color_transfer'] | |
colorspace = video['color_space'] | |
if pix_fmt == 'yuv420p10le' | |
color_primaries ||= 'bt2020' | |
color_trc ||= 'smpte2084' | |
colorspace ||= 'bt2020nc' | |
end | |
if width > 1920 or height > 1080 | |
maxrate = 25000 | |
elsif width > 1280 or height > 720 | |
maxrate = 12000 | |
elsif width > 720 or height > 576 | |
maxrate = 10000 | |
else | |
color_primaries ||= (width == 720 and height == 576 and video['codec_name'] == 'mpeg2video') ? 'bt470bg' : 'smpte170m' | |
colorspace ||= 'smpte170m' | |
maxrate = 6000 | |
end | |
color_primaries ||= 'bt709' | |
color_trc ||= 'bt709' | |
colorspace ||= 'bt709' | |
bitrate = [[@bitrate, 100].max, maxrate - 100].min unless @bitrate.nil? | |
Kernel.warn "#{sprintf("%2d", video['index'])} = " + (@bitrate.nil? ? "#{@cq} CQ" : "#{bitrate} Kbps") + ' video' | |
options += [ | |
*(cuda ? [] : ['-pix_fmt:v', 'p010le']), | |
'-c:v', 'hevc_nvenc', | |
*(@bitrate.nil? ? ['-cq:v', @cq] : ['-b:v', "#{bitrate}k"]), | |
'-maxrate:v', "#{maxrate}k", | |
'-bufsize:v', "#{maxrate}k", | |
*(@preset.nil? ? [] : ['-preset:v', @preset]), | |
'-spatial-aq:v', '1', | |
'-rc-lookahead:v', '32', | |
*(@bframe_refs ? ['-b_ref_mode:v', 'middle'] : []), | |
*(@preview ? [] : ['-bsf:v', 'filter_units=remove_types=35|38-40']), | |
'-color_primaries:v', color_primaries, | |
'-color_trc:v', color_trc, | |
'-colorspace:v', colorspace, | |
'-metadata:s:v', 'title=', | |
'-disposition:v', 'default' | |
] | |
end | |
def get_audio_options(audio) | |
codec = audio['codec_name'] | |
channels = audio['channels'].to_i | |
if @eac3 | |
encoder = codec =~ /ac3/ ? 'copy' : 'eac3' | |
else | |
encoder = (codec == 'aac' and channels <= 6) ? 'copy' : @aac_encoder | |
end | |
Kernel.warn "#{sprintf("%2d", audio['index'])} = #{encoder} audio" | |
options = [ | |
'-map', "0:#{audio['index']}", | |
'-c:a', encoder, | |
*((encoder == 'libfdk_aac' and @fdk_vbr_mode != '0') ? ['-vbr:a', @fdk_vbr_mode] : []), | |
*((encoder != 'copy' and channels > 2) ? (@eac3 ? ['-b:a', '640k'] : ['-ac:a', '6']) : []), | |
*((encoder != 'copy' and channels > 6 and @eac3) ? ['-ac:a', '6'] : []), | |
*((encoder != 'copy' and audio['sample_rate'] != '48000') ? ['-ar:a', '48000'] : []), | |
'-metadata:s:a', 'title=', | |
'-disposition:a', 'default' | |
] | |
end | |
def get_subtitle_options(forced_subtitle, sdh_subtitle, other_subtitle) | |
options = [] | |
index = 0 | |
unless forced_subtitle.nil? | |
Kernel.warn "#{sprintf("%2d", forced_subtitle['index'])} = Forced subtitle" | |
options += [ | |
'-map', "0:#{forced_subtitle['index']}", | |
"-c:s:#{index}", 'copy', | |
"-metadata:s:s:#{index}", 'title=Forced', | |
"-disposition:s:#{index}", 'default+forced' | |
] | |
index += 1 | |
end | |
if sdh_subtitle.nil? | |
unless other_subtitle.nil? | |
Kernel.warn "#{sprintf("%2d", other_subtitle['index'])} = (no name) subtitle" | |
options += [ | |
'-map', "0:#{other_subtitle['index']}", | |
"-c:s:#{index}", 'copy', | |
"-metadata:s:s:#{index}", 'title=', | |
"-disposition:s:#{index}", '0' | |
] | |
end | |
else | |
Kernel.warn "#{sprintf("%2d", sdh_subtitle['index'])} = SDH subtitle" | |
options += [ | |
'-map', "0:#{sdh_subtitle['index']}", | |
"-c:s:#{index}", 'copy', | |
"-metadata:s:s:#{index}", 'title=SDH', | |
"-disposition:s:#{index}", '0' | |
] | |
end | |
options | |
end | |
end | |
end | |
Transcoding::Command.new.run |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment