Skip to content

Instantly share code, notes, and snippets.

@lisamelton
Last active October 12, 2022 21:53
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save lisamelton/aab13b95e97901eb106527e47fbaa1ce to your computer and use it in GitHub Desktop.
Save lisamelton/aab13b95e97901eb106527e47fbaa1ce to your computer and use it in GitHub Desktop.
Cross-platform Ruby script 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
#
# classic-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
classic-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 8-bit H.264 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 use constant quality intead of constant bitrate
-m, --maxrate LIMIT set video maximum bitrate limit
--preset NAME apply video encoder preset
--deinterlace reduce interlace artifacts without changing frame rate
(applied automatically for some inputs)
--blur reduce noise with temporary change in resolution
--sharpen enhance texture with change to deblocking loop filter
--x264-options KEY=VALUE[:KEY=VALUE]...
override x264 video encoder configuration
--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 = nil
@maxrate = nil
@preset = nil
@deinterlace = false
@blur = false
@sharpen = false
@x264_options = []
@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, 1].max, 51].min.to_s.sub(/\.0$/, '')
end
opts.on '-m', '--maxrate ARG', Integer do |arg|
@maxrate = arg
end
opts.on '--preset ARG' do |arg|
@preset = case arg
when 'superfast', 'veryfast', 'faster', 'fast', 'slow', 'slower', 'veryslow'
arg
when 'medium'
nil
else
fail UsageError, "invalid preset: #{arg}"
end
end
opts.on '--deinterlace' do
@deinterlace = true
end
opts.on '--blur' do
@blur = true
end
opts.on '--sharpen' do
@sharpen = true
end
opts.on '--x264-options ARG' do |arg|
arg.split ':' do |option|
fail UsageError, "invalid argument: #{arg}" unless option =~ /^[\w\-]+=[\w\-\.,\(\)\/=]+$/
@x264_options += [option]
end
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)
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,
*(video['codec_name'] == 'vc1' ? ['-hwaccel', 'auto'] : []),
'-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...'
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,
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
width = video['width'].to_i
height = video['height'].to_i
if @blur
filters = ["scale=#{(width / 6) * 5}:#{(height / 6) * 5}", "scale=#{width}:#{height}"]
else
filters = []
end
filters += ['yadif'] 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 width > 1920 or height > 1080
level = '5.1'
maxrate = 15000
max_bitrate = 135000
elsif width > 1280 or height > 720
level = '4'
maxrate = 7500
max_bitrate = 20000
elsif width > 720 or height > 576
level = '3.1'
maxrate = 4500
max_bitrate = 14000
else
color_primaries ||= (width == 720 and height == 576 and video['codec_name'] == 'mpeg2video') ? 'bt470bg' : 'smpte170m'
colorspace ||= 'smpte170m'
level = '3'
maxrate = 2250
max_bitrate = 10000
end
color_primaries ||= 'bt709'
color_trc ||= 'bt709'
colorspace ||= 'bt709'
maxrate = [[@maxrate, 1000].max, max_bitrate].min unless @maxrate.nil?
Kernel.warn "#{sprintf("%2d", video['index'])} = " + (@cq.nil? ? "#{maxrate} Kbps" : "#{@cq} CQ") + ' video'
options += [
*(video.fetch('pix_fmt', 'yuv420p') == 'yuv420p' ? [] : ['-pix_fmt:v', 'yuv420p']),
'-c:v', 'libx264',
'-level:v', level,
*(@cq.nil? ? ['-b:v', "#{maxrate}k"] : ['-crf:v', @cq]),
'-maxrate:v', "#{maxrate}k",
'-bufsize:v', "#{maxrate}k",
*(@x264_options.empty? ? [] : ['-x264opts:v', @x264_options.join(':')]),
*(@preset.nil? ? [] : ['-preset:v', @preset]),
*(@sharpen ? ['-deblock:v', '-3:-3'] : []),
*(@preview ? [] : ['-bsf:v', 'filter_units=pass_types=1-5']),
'-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