Skip to content

Instantly share code, notes, and snippets.

@lisamelton
Last active November 9, 2022 22:35
Show Gist options
  • Save lisamelton/7c9da839d69ca9a90d1b000e5762b3e3 to your computer and use it in GitHub Desktop.
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.
#!/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