Last active
February 27, 2023 00:42
-
-
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.
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 | |
# | |
# 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