Skip to content

Instantly share code, notes, and snippets.

@lisamelton
Last active February 27, 2023 00:42
Show Gist options
  • Save lisamelton/a7551a8fc369f40ec98f349f8d5551e0 to your computer and use it in GitHub Desktop.
Save lisamelton/a7551a8fc369f40ec98f349f8d5551e0 to your computer and use it in GitHub Desktop.
Cross-platform Ruby script to transcode essential media tracks into a smaller, more portable format while remaining high enough quality to be mistaken for the original.
#!/usr/bin/env ruby
#
# hb-av1-transcode.rb
#
# Copyright (c) 2019-2023 Don Melton
#
require 'English'
require 'fileutils'
require 'json'
require 'optparse'
module Transcoding
class UsageError < RuntimeError
end
class Command
def about
<<-HERE
hb-av1-transcode.rb 0.0.02023022601
Copyright (c) 2019-2023 Don Melton
HERE
end
def usage
<<-HERE
Transcode 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 AV1 format and audio in multichannel Opus format.
Forced subtitles are automatically burned or included.
Options:
--debug increase diagnostic information
-n, --dry-run don't transcode, just show `HandBrakeCLI` command
-p, --preset NUMBER apply video encoder preset (default: 8)
-q, --quality VALUE set constant quality value (default: 30)
-b, --bitrate TARGET use variable bitrate instead of constant quality
--crop TOP:BOTTOM:LEFT:RIGHT|none|auto
set video crop geometry (default: auto)
-x, --extra NAME[=VALUE]
add `HandBrakeCLI` option by name or name with value
-h, --help display this help and exit
--version output version information and exit
Requires `HandBrakeCLI`, `ffprobe` and `mkvpropedit`.
HERE
end
def initialize
@debug = false
@dry_run = false
@preset = '8'
@quality = '30'
@bitrate = nil
@crop = nil
@extra_options = {}
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?
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 '--debug' do
@debug = true
end
opts.on '-n', '--dry-run' do
@dry_run = true
end
opts.on '-p', '--preset ARG', Integer do |arg|
@preset = [[arg, -1].max, 13].min.to_s
end
opts.on '-q', '--quality ARG', Integer do |arg|
@quality = [[arg, 0].max, 63].min.to_s
@bitrate = nil
end
opts.on '-b', '--bitrate ARG', Integer do |arg|
@bitrate = [arg, 1].max.to_s
end
opts.on '--crop ARG' do |arg|
case arg
when /^([0-9]+):([0-9]+):([0-9]+):([0-9]+)$/
@crop = arg
when 'none'
@crop = '0:0:0:0'
when 'auto'
@crop = nil
else
fail UsageError, "invalid crop geometry: #{arg}"
end
end
opts.on '-x', '--extra ARG' do |arg|
unless arg =~ /^([a-zA-Z][a-zA-Z0-9-]+)(?:=(.+))?$/
fail UsageError, "invalid HandBrakeCLI option: #{arg}"
end
name = $1
value = $2
case name
when 'help', 'version', 'json', /^preset/, 'queue-import-file',
'input', 'title', 'scan', 'main-feature', 'previews', 'output',
'format', 'encoder', 'encoder-preset', /^encoder-[^-]+-list$/,
'quality', 'vb', 'crop'
fail UsageError, "unsupported HandBrakeCLI option name: #{name}"
end
@extra_options[name] = value
end
end
def process_input(path)
seconds = Time.now.tv_sec
media_info = scan_media(path)
video_options = get_video_options(media_info)
audio_options = get_audio_options(media_info)
subtitle_options = get_subtitle_options(media_info)
output = File.basename(path, '.*') + '.mkv'
fail "output file already exists: #{output}" if File.exist? output
handbrake_command = [
'HandBrakeCLI',
'--input', path,
'--output', output,
*video_options,
*audio_options,
*subtitle_options
]
@extra_options.each do |name, value|
handbrake_command << "--#{name}"
handbrake_command << value unless value.nil?
end
command_line = escape_command(handbrake_command)
Kernel.warn 'Command line:'
if @dry_run
puts command_line
return
end
Kernel.warn command_line
Kernel.warn 'Transcoding...'
fail "transcoding failed: #{output}" if system(*handbrake_command).nil?
fail "property editing failed: #{output}" unless system(
'mkvpropedit',
'--quiet',
output,
'--set', 'muxing-application=',
'--set', 'writing-application=',
'--edit', 'track:a1',
'--delete', 'name'
)
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_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 seconds_to_time(seconds)
sprintf("%02d:%02d:%02d", seconds / (60 * 60), (seconds / 60) % 60, seconds % 60)
end
def get_video_options(media_info)
video = nil
media_info['streams'].each do |stream|
if stream['codec_type'] == 'video'
video = stream
break
end
end
fail "video track not found: #{arg}" if video.nil?
options = [
'--encoder', 'svt_av1_10bit',
'--encoder-preset', @preset
]
options += @bitrate.nil? ? ['--quality', @quality] : ['--vb', @bitrate]
unless @extra_options.include? 'rate' or
@extra_options.include? 'vfr' or
@extra_options.include? 'cfr' or
@extra_options.include? 'pfr'
if video['codec_name'] == 'mpeg2video' and video['avg_frame_rate'] == '30000/1001'
options += ['--rate', '29.97', '--cfr']
else
options += ['--rate', '60']
end
end
options += ['--crop', @crop] unless @crop.nil?
options
end
def get_audio_options(media_info)
first_audio = nil
audio = nil
index = 0
media_info['streams'].each do |stream|
if stream['codec_type'] == 'audio'
first_audio ||= stream
index += 1
if stream['disposition']['default'] == 1
audio = stream
break
end
end
end
if audio.nil?
audio = first_audio
index = 1
end
fail "audio track not found: #{arg}" if audio.nil?
options = []
unless @extra_options.include? 'audio' or
@extra_options.include? 'all-audio' or
@extra_options.include? 'first-audio'
options += ['--audio', index.to_s]
unless @extra_options.include? 'aencoder'
case audio['channels'].to_i
when 1
bitrate = '64'
mixdown = 'mono'
when 2
bitrate = '96'
mixdown = 'stereo'
else
bitrate = '320'
mixdown = '5point1'
end
options += ['--aencoder', 'opus']
options += ['--ab', bitrate] unless @extra_options.include? 'ab'
options += ['--mixdown', mixdown] unless @extra_options.include? 'mixdown'
end
end
options
end
def get_subtitle_options(media_info)
subtitle = nil
index = 0
media_info['streams'].each do |stream|
if stream['codec_type'] == 'subtitle' and stream['disposition']['forced'] == 1
subtitle = stream
index += 1
break
end
end
return [] if subtitle.nil?
unless @extra_options.include? 'subtitle' or
@extra_options.include? 'all-subtitles' or
@extra_options.include? 'first-subtitle'
options = ['--subtitle', index.to_s]
if subtitle['codec_name'] == 'hdmv_pgs_subtitle' or subtitle['codec_name'] == 'dvd_subtitle'
options += ['--subtitle-burned']
else
options += ['--subtitle-default']
end
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