├── .gitignore ├── LICENSE ├── CHANGELOG.md ├── detect-crop.rb ├── convert-video.rb ├── README.md └── transcode-video.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Lisa Melton 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes to the "[Video Transcoding](https://github.com/lisamelton/video_transcoding)" project 2 | 3 | ## [2025.01.28](https://github.com/lisamelton/video_transcoding/releases/tag/2025.01.28) 4 | 5 | Tuesday, January 28, 2025 6 | 7 | * Change the `rc-lookahead` value for the `nvenc-hevc` video mode in `transcode-video.rb` from `32` to `20` per current Nvidia guidelines. A value of `32` is the maximum allowed but it's probably unnecessary. 8 | * Add ratecontrol code for the `nvenc-av1` video mode which functionally matches that of `nvenc-hevc` mode. 9 | * Change the `nvenc-av1` video mode `quality` value from `35` to `37`. This will lower output bitrates below that of `nvenc-hevc` mode, a sensible move because AV1 format is supposed to be more size-efficient than HEVC at the same perceived level of quality. 10 | 11 | ## [2025.01.24](https://github.com/lisamelton/video_transcoding/releases/tag/2025.01.24) 12 | 13 | Friday, January 24, 2025 14 | 15 | * Fix the bogus VBV being set when using a custom encoder with `transcode-video.rb`. This bug was introduced by the previous change. 16 | 17 | ## [2025.01.23](https://github.com/lisamelton/video_transcoding/releases/tag/2025.01.23) 18 | 19 | Thursday, January 23, 2025 20 | 21 | * Add missing ratecontrol code for the `nvenc-hevc` video mode that was _stupidly_ left out of the original rewrite of `transcode-video.rb`. This also implements the `--no-bframe-refs` option. 22 | 23 | ## [2025.01.19](https://github.com/lisamelton/video_transcoding/releases/tag/2025.01.19) 24 | 25 | Sunday, January 19, 2025 26 | 27 | * Add `nvenc-av1` video mode to `transcode-video.rb`. 28 | 29 | ## [2025.01.10](https://github.com/lisamelton/video_transcoding/releases/tag/2025.01.10) 30 | 31 | Friday, January 10, 2025 32 | 33 | * Fix bug preventing `encopts` arguments being passed to the `--extra` option of `transcode-video.rb`. 34 | * Clarify that the automatic behavior of `transcode-video.rb` described in the `README.md` file is for a single forced subtitle and does not apply to multiple subtitles. 35 | * Add note to the `README.md` file regarding possible future video modes for `transcode-video.rb`. 36 | 37 | ## [2025.01.09](https://github.com/lisamelton/video_transcoding/releases/tag/2025.01.09) 38 | 39 | Thursday, January 9, 2025 40 | 41 | * Deprecate and remove legacy [RubyGems](https://en.wikipedia.org/wiki/RubyGems)-based project files. 42 | * Remove `*.gem` files from the list to ignore. 43 | * Add redesigned and rewritten tools to the project, i.e. the `transcode-video.rb`, `detect-crop.rb` and `convert-video.rb` scripts. 44 | * Completely update the `README.md` file. 45 | * Begin using a date-based version numbering scheme for the project and all the scripts. 46 | 47 | > [!NOTE] 48 | > Changes before version 2025.01.09 are no longer relevant and not included in this document. 49 | -------------------------------------------------------------------------------- /detect-crop.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # detect-crop.rb 4 | # 5 | # Copyright (c) 2025 Lisa Melton 6 | # 7 | 8 | require 'English' 9 | require 'open3' 10 | require 'optparse' 11 | require 'tempfile' 12 | 13 | module Cropping 14 | 15 | class UsageError < RuntimeError 16 | end 17 | 18 | class Command 19 | def about 20 | <<-HERE 21 | detect-crop.rb 2025.01.28 22 | Copyright (c) 2025 Lisa Melton 23 | HERE 24 | end 25 | 26 | def usage 27 | <<-HERE 28 | Detect unused outside area of video tracks. 29 | 30 | Usage: #{File.basename($PROGRAM_NAME)} [OPTION]... [FILE]... 31 | 32 | Prints TOP:BOTTOM:LEFT:RIGHT crop values to standard output 33 | 34 | Options: 35 | -m, --mode auto|conservative 36 | set crop mode (default: conservative) 37 | -h, --help display this help and exit 38 | --version output version information and exit 39 | 40 | Requires `HandBrakeCLI`. 41 | HERE 42 | end 43 | 44 | def initialize 45 | @input_count = 0 46 | @mode = 'conservative' 47 | end 48 | 49 | def run 50 | begin 51 | OptionParser.new do |opts| 52 | define_options opts 53 | 54 | opts.on '-h', '--help' do 55 | puts usage 56 | exit 57 | end 58 | 59 | opts.on '--version' do 60 | puts about 61 | exit 62 | end 63 | end.parse! 64 | rescue OptionParser::ParseError => e 65 | raise UsageError, e 66 | end 67 | 68 | fail UsageError, 'missing argument' if ARGV.empty? 69 | 70 | @input_count = ARGV.count 71 | ARGV.each { |arg| process_input arg } 72 | exit 73 | rescue UsageError => e 74 | Kernel.warn "#{$PROGRAM_NAME}: #{e}" 75 | Kernel.warn "Try `#{File.basename($PROGRAM_NAME)} --help` for more information." 76 | exit false 77 | rescue StandardError => e 78 | Kernel.warn "#{$PROGRAM_NAME}: #{e}" 79 | exit(-1) 80 | rescue SignalException 81 | puts 82 | exit(-1) 83 | end 84 | 85 | def define_options(opts) 86 | opts.on '--debug' do 87 | @debug = true 88 | end 89 | 90 | opts.on '-m', '--mode ARG' do |arg| 91 | @mode = case arg 92 | when 'auto', 'conservative' 93 | arg 94 | else 95 | fail UsageError, "unsupported crop mode: #{arg}" 96 | end 97 | end 98 | end 99 | 100 | def process_input(path) 101 | output = Tempfile.new('detect-crop') 102 | 103 | begin 104 | unused, info, status = Open3.capture3( 105 | 'HandBrakeCLI', 106 | '--input', path, 107 | '--output', output.path, 108 | '--format', 'av_mkv', 109 | '--stop-at', 'seconds:1', 110 | '--crop-mode', @mode, 111 | '--encoder', 'x265_10bit', 112 | '--encoder-preset', 'ultrafast', 113 | '--audio', '0' 114 | ) 115 | fail "scanning media failed: #{path}" unless status.exitstatus == 0 116 | ensure 117 | output.close 118 | output.unlink 119 | end 120 | 121 | crop = '0:0:0:0' 122 | 123 | info.each_line do |line| 124 | next unless line.valid_encoding? 125 | 126 | if line =~ %r{, crop \((\d+/\d+/\d+/\d+)\): } 127 | crop = $1.gsub('/', ':') 128 | break 129 | end 130 | end 131 | 132 | puts crop + (@input_count > 1 ? ",\"#{path.gsub(/"/, '""')}\"" : '') 133 | end 134 | end 135 | end 136 | 137 | Cropping::Command.new.run 138 | -------------------------------------------------------------------------------- /convert-video.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # convert-video.rb 4 | # 5 | # Copyright (c) 2025 Lisa Melton 6 | # 7 | 8 | require 'English' 9 | require 'json' 10 | require 'optparse' 11 | 12 | module Converting 13 | 14 | class UsageError < RuntimeError 15 | end 16 | 17 | class Command 18 | def about 19 | <<-HERE 20 | convert-video.rb 2025.01.28 21 | Copyright (c) 2025 Lisa Melton 22 | HERE 23 | end 24 | 25 | def usage 26 | <<-HERE 27 | Convert media file from Matroska to MP4 format or other media to MKV 28 | without transcoding. 29 | 30 | Usage: #{File.basename($PROGRAM_NAME)} [OPTION]... [FILE]... 31 | 32 | All video, audio and subtitle tracks are copied during conversion. 33 | But incompatible subtitles are ignored when converting to MP4 format. 34 | 35 | Options: 36 | --debug increase diagnostic information 37 | -n, --dry-run don't transcode, just show `ffmpeg` command 38 | -h, --help display this help and exit 39 | --version output version information and exit 40 | 41 | Requires `ffmpeg` and `ffprobe`. 42 | HERE 43 | end 44 | 45 | def initialize 46 | @debug = false 47 | @dry_run = false 48 | end 49 | 50 | def run 51 | begin 52 | OptionParser.new do |opts| 53 | define_options opts 54 | 55 | opts.on '-h', '--help' do 56 | puts usage 57 | exit 58 | end 59 | 60 | opts.on '--version' do 61 | puts about 62 | exit 63 | end 64 | end.parse! 65 | rescue OptionParser::ParseError => e 66 | raise UsageError, e 67 | end 68 | 69 | fail UsageError, 'missing argument' if ARGV.empty? 70 | 71 | ARGV.each { |arg| process_input arg } 72 | exit 73 | rescue UsageError => e 74 | Kernel.warn "#{$PROGRAM_NAME}: #{e}" 75 | Kernel.warn "Try `#{File.basename($PROGRAM_NAME)} --help` for more information." 76 | exit false 77 | rescue StandardError => e 78 | Kernel.warn "#{$PROGRAM_NAME}: #{e}" 79 | exit(-1) 80 | rescue SignalException 81 | puts 82 | exit(-1) 83 | end 84 | 85 | def define_options(opts) 86 | opts.on '--debug' do 87 | @debug = true 88 | end 89 | 90 | opts.on '-n', '--dry-run' do 91 | @dry_run = true 92 | end 93 | end 94 | 95 | def process_input(path) 96 | seconds = Time.now.tv_sec 97 | media_info = scan_media(path) 98 | options = ['-c:v', 'copy', '-c:a', 'copy'] 99 | 100 | if media_info['format']['format_name'] =~ /matroska/ 101 | extension = '.mp4' 102 | index = 0 103 | 104 | media_info['streams'].each do |stream| 105 | map_stream = false 106 | codec_name = nil 107 | 108 | case stream['codec_type'] 109 | when 'video', 'audio' 110 | map_stream = true 111 | when 'subtitle' 112 | case stream['codec_name'] 113 | when 'dvd_subtitle' 114 | map_stream = true 115 | codec_name = 'copy' 116 | when 'subrip' 117 | map_stream = true 118 | codec_name = 'mov_text' 119 | else 120 | Kernel.warn "Ignoring subtitle track \##{index + 1}" 121 | end 122 | 123 | index += 1 124 | end 125 | 126 | options += ['-map', "0:#{stream['index']}"] if map_stream 127 | options += ["-c:s:#{index - 1}", codec_name] unless codec_name.nil? 128 | end 129 | 130 | options += ['-movflags', 'disable_chpl'] 131 | else 132 | extension = '.mkv' 133 | options += ['-c:s', 'copy'] 134 | end 135 | 136 | output = File.basename(path, '.*') + extension 137 | 138 | ffmpeg_command = [ 139 | 'ffmpeg', 140 | '-loglevel', (@debug ? 'verbose' : 'error'), 141 | '-stats', 142 | '-i', path, 143 | *options, 144 | output 145 | ] 146 | 147 | command_line = escape_command(ffmpeg_command) 148 | Kernel.warn 'Command line:' 149 | 150 | if @dry_run 151 | puts command_line 152 | return 153 | end 154 | 155 | Kernel.warn command_line 156 | fail "output file already exists: #{output}" if File.exist? output 157 | 158 | Kernel.warn 'Converting...' 159 | system(*ffmpeg_command, exception: true) 160 | Kernel.warn "\nElapsed time: #{seconds_to_time(Time.now.tv_sec - seconds)}\n\n" 161 | end 162 | 163 | def scan_media(path) 164 | Kernel.warn 'Scanning media...' 165 | media_info = '' 166 | 167 | IO.popen([ 168 | 'ffprobe', 169 | '-loglevel', 'quiet', 170 | '-show_streams', 171 | '-show_format', 172 | '-print_format', 'json', 173 | path 174 | ]) do |io| 175 | media_info = io.read 176 | end 177 | 178 | fail "scanning media failed: #{path}" unless $CHILD_STATUS.exitstatus == 0 179 | 180 | begin 181 | media_info = JSON.parse(media_info) 182 | rescue JSON::JSONError 183 | fail "media information not found: #{path}" 184 | end 185 | 186 | Kernel.warn media_info.inspect if @debug 187 | media_info 188 | end 189 | 190 | def escape_command(command) 191 | command_line = '' 192 | command.each {|item| command_line += "#{escape_string(item)} " } 193 | command_line.sub!(/ $/, '') 194 | command_line 195 | end 196 | 197 | def escape_string(str) 198 | # See: https://github.com/larskanis/shellwords 199 | return '""' if str.empty? 200 | 201 | str = str.dup 202 | 203 | if RUBY_PLATFORM =~ /mingw/ 204 | str.gsub!(/((?:\\)*)"/) { "\\" * ($1.length * 2) + "\\\"" } 205 | 206 | if str =~ /\s/ 207 | str.gsub!(/(\\+)\z/) { "\\" * ($1.length * 2 ) } 208 | str = "\"#{str}\"" 209 | end 210 | else 211 | str.gsub!(/([^A-Za-z0-9_\-.,:\/@\n])/, "\\\\\\1") 212 | str.gsub!(/\n/, "'\n'") 213 | end 214 | 215 | str 216 | end 217 | 218 | def seconds_to_time(seconds) 219 | sprintf("%02d:%02d:%02d", seconds / (60 * 60), (seconds / 60) % 60, seconds % 60) 220 | end 221 | end 222 | end 223 | 224 | Converting::Command.new.run 225 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Video Transcoding 2 | 3 | Tools to transcode, inspect and convert videos. 4 | 5 | ## About 6 | 7 | > [!NOTE] 8 | > *This decade-old project was redesigned and rewritten for the modern era of video transcoding, and then re-released in early 2025 with different behavior and incompatible APIs. Some old conveniences were removed but new features and flexibility were added. Please manage your expectations accordingly if you came here looking for the older tools.* 9 | 10 | Hi, I'm [Lisa Melton](http://lisamelton.net/). I created these tools to transcode my collection of Blu-ray Discs and DVDs into a smaller, more portable format while remaining high enough quality to be mistaken for the originals. 11 | 12 | Most of the tools in this package are essentially intelligent wrappers around Open Source software like [HandBrake](https://handbrake.fr/) and [FFmpeg](http://ffmpeg.org/). And they're all designed to be executed from the command line shell: 13 | 14 | * `transcode-video.rb` 15 | Transcode essential media tracks into a smaller, more portable format while remaining high enough quality to be mistaken for the original. 16 | 17 | * `detect-crop.rb` 18 | Detect the unused outside area of video tracks and print TOP:BOTTOM:LEFT:RIGHT crop values to standard output. 19 | 20 | * `convert-video.rb` 21 | Convert a media file from Matroska `.mkv` format to MP4 format or other media to Matroksa format without transcoding. 22 | 23 | ## Installation 24 | 25 | > [!WARNING] 26 | > *Older versions of this project were packaged via [RubyGems](https://en.wikipedia.org/wiki/RubyGems) and installed via the `gem` command. If you had it installed that way, it's a good idea to uninstall that version via this command: `gem uninstall video_transcoding`* 27 | 28 | These tools work on Windows, Linux and macOS. They're standalone Ruby scripts which must be installed and updated manually. You can retrieve them via the command line by cloning the entire repository like this: 29 | 30 | git clone https://github.com/lisamelton/video_transcoding.git 31 | 32 | Or download it directly from the GitHub website here: 33 | 34 | https://github.com/lisamelton/video_transcoding 35 | 36 | On Linux and macOS, make sure each script is executable by setting their permissions like this: 37 | 38 | chmod +x transcode-video.rb 39 | chmod +x detect-crop.rb 40 | chmod +x convert-video.rb 41 | 42 | And then move or copy them to a directory listed in your `$env:PATH` environment variable on Windows or `$PATH` environment variable on Linux and macOS. 43 | 44 | Because they're written in Ruby, each script requires that language's runtime and interpreter. See "[Installing Ruby](https://www.ruby-lang.org/en/documentation/installation/)" if you don't have it on your platform. 45 | 46 | Additional software is required for all the scripts to function properly, specifically these command line programs: 47 | 48 | * `HandBrakeCLI` 49 | * `ffprobe` 50 | * `ffmpeg` 51 | 52 | See "[HandBrake Downloads (Command Line)](https://handbrake.fr/downloads2.php)" and "[Download FFmpeg](https://ffmpeg.org/download.html) to find versions for your platform. 53 | 54 | On macOS, all of these programs can be easily installed via [Homebrew](http://brew.sh/), an optional package manager: 55 | 56 | brew install handbrake 57 | brew install ffmpeg 58 | 59 | The `ffprobe` program is included within the `ffmpeg` package. 60 | 61 | On Windows, it's best to follow one of the two methods described here, manually installing binaries or installing into the [Windows Subsystem for Linux](https://en.wikipedia.org/wiki/Windows_Subsystem_for_Linux): 62 | 63 | https://github.com/JMoVS/installing_video_transcoding_on_windows 64 | 65 | ## Usage 66 | 67 | For each tool in this package, use `--help` to list the options available for that tool along with brief instructions on their usage. For example: 68 | 69 | transcode-video.rb --help 70 | 71 | And since all of the tools take one or more media files as arguments, using them can be as simple as this on Windows: 72 | 73 | transcode-video.rb C:\Rips\Movie.mkv 74 | 75 | Or this on Linux and macOS: 76 | 77 | transcode-video.rb /Rips/Movie.mkv 78 | 79 | ## Default `transcode-video.rb` behavior 80 | 81 | The `transcode-video.rb` tool 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. 82 | 83 | 4K inputs are automatically scaled to 1080p and HDR is automatically converted to SDR color space. 84 | 85 | Video is automatically cropped. 86 | 87 | The first audio track in the input, if available, is automatically selected. 88 | 89 | Any forced subtitle is automatically burned into the video track or included as a separate text-only track depending on its original format. 90 | 91 | The venerable `x264` software-based encoder is used with two-pass ratecontrol to produce a constant bitrate. Using two passes _is_ a bit slower than other methods but the output quality is worth the wait, as is the output size. This Is The Way™. 92 | 93 | **Video:** 94 | 95 | Resolution | H.264 bitrate 96 | --- | --- 97 | 1080p (Blu-ray) | 5000 Kbps 98 | 720p | 2500 Kbps 99 | 480p (DVD) | 1250 Kbps 100 | 101 | **Audio:** 102 | 103 | Channels | AAC bitrate 104 | --- | --- 105 | Surround | 384 Kbps 106 | Stereo | 128 Kbps 107 | Mono | 80 Kbps 108 | 109 | All this behavior can easily be changed by selecting different video and audio modes via the `--mode` and `--audio-mode` options, using other options like `--add-audio` or by passing arguments directly to the `HandBrakeCLI` API via the `--extra` option. It's very, very flexible. 110 | 111 | ## Other video modes 112 | 113 | While the default behavior of `transcode-video.rb` is focused on creating high-quality 1080p and smaller-resolution SDR videos, other modes are available. 114 | 115 | ### `--mode hevc` 116 | 117 | Designed for 4K HDR content, this mode uses the `x265_10bit` software-based encoder with a constant quality (instead of a constant bitrate) ratecontrol system. But it's reeeeeally slow. I mean, really slow. However, it does produce high-quality output. You just have to decide whether it's worth it. 118 | 119 | One big selling point is that the `x265_10bit` encoder can produce output compatible with both the [HDR10](https://en.wikipedia.org/wiki/HDR10) and [HDR10+](https://en.wikipedia.org/wiki/HDR10%2B) standards as well as [Dolby Vision](https://en.wikipedia.org/wiki/Dolby_Vision). 120 | 121 | ### `--mode nvenc-hevc` 122 | 123 | Also designed for 4K HDR content, this mode uses the `nvenc_h265_10bit` Nvidia hardware-based encoder, also with a constant quality ratecontrol system, because you can't always afford to wait on `x265_10bit`. The output will be slightly larger and somewhat lesser in quality but you'll get it a LOT faster. A lot. 124 | 125 | But be aware that the `nvenc_h265_10bit` encoder can only produce HDR10-compatible output. 126 | 127 | ### `--mode av1` 128 | 129 | This Is The Future. Unfortunately, the [AV1 video format](https://en.wikipedia.org/wiki/AV1) is currently the Star Trek Future. Other than desktop PCs, most devices can't play it yet. This mode uses the `svt_av1_10bit` software-based encoder with a constant quality ratecontrol system. Although the encoder is already quite good, it's still a work in progress. But it's faster than `x265_10bit` and usually produces smaller output. So it's certainly worth a try. Especially on 4K HDR content. 130 | 131 | The `svt_av1_10bit` encoder can produce output compatible with the HDR10 and HDR10+ standards and pass through Dolby Vision metadata. 132 | 133 | When using this mode, audio output is in Opus format at slightly lower bitrates. Why Opus? Because it's higher quality than AAC and if you can play AV1 format video then you can certainly play Opus format audio. 134 | 135 | ### `--mode nvenc-av1` 136 | 137 | This mode uses the `nvenc_av1_10bit` Nvidia hardware-based encoder, also with a constant quality ratecontrol system. The output is actually about the same size as that from the software-based `svt_av1_10bit` encoder in `av1` mode, but this is MUCH faster. 138 | 139 | Be aware that, like other Nvidia encoders, `nvenc_h265_10bit` can only produce HDR10-compatible output. And like the `av1` mode, audio output is in Opus format at slightly lower bitrates. 140 | 141 | > [!NOTE] 142 | > *An additional `--mode` argument leveraging the `vt_h265_10bit` video encoder, likely to be named `vt-hevc`, is under consideration pending ratecontrol tuning which will be delayed until I actually have an Apple Silicon Mac.* 143 | 144 | ## Calling `HandBrakeCLI` from `transcode-video.rb` 145 | 146 | The `transcode-video.rb` tool has less than 20 options. But the `HandBrakeCLI` API has over 100. It's YUUUUUGE! And you can pass arguments directly to that API via the `--extra` option. 147 | 148 | But use the `-x` shortcut because who wants to do all that work typing `--extra`. 149 | 150 | Even though the `convert-video.rb` tool is included in this project, you can output to MP4 format from `transcode-video.rb` itself like this: 151 | 152 | transcode-video.rb -x format=av_mp4 C:\Rips\Movie.mkv 153 | 154 | What if you want to tweak a crop instead of relying on `HandBrakeCLI`'s new and improved algorithm? It's as simple as: 155 | 156 | transcode-video.rb -x crop=140:140:0:0 C:\Rips\Movie.mkv 157 | 158 | If you want to get faster results and are willing to live dangerously when using `x264`, you can disable two-pass transcoding like this: 159 | 160 | transcode-video.rb -x no-multi-pass C:\Rips\Movie.mkv 161 | 162 | What about filters? Easy peasy. You can apply any of `HandBrakeCLI`'s built-in filters this way: 163 | 164 | transcode-video.rb -x detelecine C:\Rips\Movie.mkv 165 | 166 | Want to waste space? Then keep your original audio track in your output by changing the audio encoder: 167 | 168 | transcode-video.rb -x aencoder=copy C:\Rips\Movie.mkv 169 | 170 | And if you just want an excerpt of your input, you can specify a chapter range for your output: 171 | 172 | transcode-video.rb -x chapters=3-5 C:\Rips\Movie.mkv 173 | 174 | ## Feedback 175 | 176 | Please report bugs or ask questions by [creating a new issue](https://github.com/lisamelton/video_transcoding/issues) on GitHub. I always try to respond quickly but sometimes it may take as long as 24 hours. 177 | 178 | ## Acknowledgements 179 | 180 | This project would not be possible without my collaborators on the [Video Transcoding Slack](https://videotranscoding.slack.com/) who spend countless hours reviewing, testing, documenting and supporting this software. 181 | 182 | ## License 183 | 184 | Video Transcoding is copyright [Lisa Melton](http://lisamelton.net/) and available under an [MIT license](https://github.com/lisamelton/video_transcoding/blob/master/LICENSE). 185 | -------------------------------------------------------------------------------- /transcode-video.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # transcode-video.rb 4 | # 5 | # Copyright (c) 2025 Lisa Melton 6 | # 7 | 8 | require 'English' 9 | require 'json' 10 | require 'optparse' 11 | 12 | module Transcoding 13 | 14 | class UsageError < RuntimeError 15 | end 16 | 17 | class Command 18 | def about 19 | <<-HERE 20 | transcode-video.rb 2025.01.28 21 | Copyright (c) 2025 Lisa Melton 22 | HERE 23 | end 24 | 25 | def usage 26 | <<-HERE 27 | Transcode essential media tracks into a smaller, more portable format 28 | while remaining high enough quality to be mistaken for the original. 29 | 30 | Usage: #{File.basename($PROGRAM_NAME)} [OPTION]... [FILE]... 31 | 32 | Creates a Matroska `.mkv` format file in the current working directory 33 | with video in 8-bit H.264 format and audio in multichannel AAC format. 34 | Forced subtitles are automatically burned or included. 35 | 36 | Options: 37 | --debug increase diagnostic information 38 | -n, --dry-run don't transcode, just show `HandBrakeCLI` command 39 | -m, --mode h264|hevc|nvenc-hevc|av1|nvenc-av1|none 40 | set video encoding mode (default: h264) 41 | -p, --preset NAME apply video encoder preset (default: 8 for av1) 42 | -b, --bitrate TARGET set video bitrate target (default: based on input) 43 | -q, --quality VALUE set constant quality value 44 | --no-bframe-refs don't use B-frames as reference frames 45 | (for compatibilty with older Nvidia GPUs) 46 | -a, --audio-mode aac|opus|eac3|none 47 | set audio encoding mode 48 | (default: aac, opus for av1 and nvenc-av1) 49 | --add-audio TRACK|LANGUAGE|STRING|all 50 | include audio track (default: 1) 51 | (can be used multiple times) 52 | --ac3-surround use AC-3 format for more compatible surround audio 53 | (aac mode only, raises default audio bitrate) 54 | --aac-encoder av_aac|fdk_aac|ca_aac 55 | select named AAC audio encoder (default: av_aac) 56 | --burn-subtitle TRACK|none 57 | burn subtitle track into video (default: automatic) 58 | (text-only subtitles are included, not burned) 59 | --add-subtitle TRACK|LANGUAGE|STRING|all 60 | include subtitle track (disables burning) 61 | (can be used multiple times) 62 | -x, --extra NAME[=VALUE] 63 | add `HandBrakeCLI` option by name or name with value 64 | -h, --help display this help and exit 65 | --version output version information and exit 66 | 67 | Requires `HandBrakeCLI` and `ffprobe`. 68 | HERE 69 | end 70 | 71 | def initialize 72 | @debug = false 73 | @dry_run = false 74 | @mode = :h264 75 | @preset = nil 76 | @bitrate = nil 77 | @quality = nil 78 | @bframe_refs = true 79 | @audio_mode = :aac 80 | @audio_selections = [{ 81 | :track => 1, 82 | :language => nil, 83 | :title => nil 84 | }] 85 | @ac3_surround = false 86 | @aac_encoder = 'av_aac' 87 | @burn_subtitle = :auto 88 | @subtitle_selections = [] 89 | @extra_options = {} 90 | @vbv_size = nil 91 | end 92 | 93 | def run 94 | begin 95 | OptionParser.new do |opts| 96 | define_options opts 97 | 98 | opts.on '-h', '--help' do 99 | puts usage 100 | exit 101 | end 102 | 103 | opts.on '--version' do 104 | puts about 105 | exit 106 | end 107 | end.parse! 108 | rescue OptionParser::ParseError => e 109 | raise UsageError, e 110 | end 111 | 112 | fail UsageError, 'missing argument' if ARGV.empty? 113 | 114 | configure 115 | ARGV.each { |arg| process_input arg } 116 | exit 117 | rescue UsageError => e 118 | Kernel.warn "#{$PROGRAM_NAME}: #{e}" 119 | Kernel.warn "Try `#{File.basename($PROGRAM_NAME)} --help` for more information." 120 | exit false 121 | rescue StandardError => e 122 | Kernel.warn "#{$PROGRAM_NAME}: #{e}" 123 | exit(-1) 124 | rescue SignalException 125 | puts 126 | exit(-1) 127 | end 128 | 129 | def define_options(opts) 130 | opts.on '--debug' do 131 | @debug = true 132 | end 133 | 134 | opts.on '-n', '--dry-run' do 135 | @dry_run = true 136 | end 137 | 138 | opts.on '-m', '--mode ARG' do |arg| 139 | @mode = case arg 140 | when 'h264', 'hevc', 'none' 141 | arg.to_sym 142 | when 'nvenc-hevc' 143 | :nvenc_hevc 144 | when 'av1' 145 | @audio_mode = :opus 146 | :av1 147 | when 'nvenc-av1' 148 | @audio_mode = :opus 149 | :nvenc_av1 150 | else 151 | fail UsageError, "unsupported video mode: #{arg}" 152 | end 153 | end 154 | 155 | opts.on '-p', '--preset ARG' do |arg| 156 | @preset = arg 157 | end 158 | 159 | opts.on '-b', '--bitrate ARG', Integer do |arg| 160 | @bitrate = arg.to_s 161 | @quality = nil 162 | end 163 | 164 | opts.on '-q', '--quality ARG', Float do |arg| 165 | @quality = arg.to_s 166 | @bitrate = nil 167 | end 168 | 169 | opts.on '--no-bframe-refs' do 170 | @bframe_refs = false 171 | end 172 | 173 | opts.on '-a', '--audio-mode ARG' do |arg| 174 | @audio_mode = case arg 175 | when 'aac', 'opus', 'eac3', 'none' 176 | arg.to_sym 177 | else 178 | fail UsageError, "unsupported audio mode: #{arg}" 179 | end 180 | end 181 | 182 | opts.on '--add-audio ARG' do |arg| 183 | selection = { 184 | :track => nil, 185 | :language => nil, 186 | :title => nil 187 | } 188 | 189 | case arg 190 | when /^[0-9]+$/ 191 | selection[:track] = arg.to_i 192 | when /^[a-z]{3}$/ 193 | selection[:language] = arg 194 | else 195 | selection[:title] = arg 196 | end 197 | 198 | @audio_selections += [selection] 199 | end 200 | 201 | opts.on '--ac3-surround' do 202 | @ac3_surround = true 203 | end 204 | 205 | opts.on '--aac-encoder ARG' do |arg| 206 | @aac_encoder = case arg 207 | when 'av_aac', 'fdk_aac', 'ca_aac' 208 | arg 209 | else 210 | fail UsageError, "invalid AAC audio encoder name: #{arg}" 211 | end 212 | end 213 | 214 | opts.on '--burn-subtitle ARG' do |arg| 215 | @burn_subtitle = case arg 216 | when /^[0-9]+$/ 217 | @subtitle_selections = [] 218 | arg.to_i 219 | when 'none' 220 | nil 221 | else 222 | fail UsageError, "invalid burn subtitle argument: #{arg}" 223 | end 224 | end 225 | 226 | opts.on '--add-subtitle ARG' do |arg| 227 | selection = { 228 | :track => nil, 229 | :language => nil, 230 | :title => nil 231 | } 232 | 233 | case arg 234 | when /^[0-9]+$/ 235 | selection[:track] = arg.to_i 236 | when /^[a-z]{3}$/ 237 | selection[:language] = arg 238 | else 239 | selection[:title] = arg 240 | end 241 | 242 | @subtitle_selections += [selection] 243 | @burn_subtitle = nil 244 | end 245 | 246 | opts.on '-x', '--extra ARG' do |arg| 247 | unless arg =~ /^([a-zA-Z][a-zA-Z0-9-]+)(?:=(.+))?$/ 248 | fail UsageError, "invalid HandBrakeCLI option: #{arg}" 249 | end 250 | 251 | name = $1 252 | value = $2 253 | 254 | case name 255 | when 'help', 'version', 'json', /^preset/, 'queue-import-file', 256 | 'input', 'output', /^encoder-[^-]+-list$/ 257 | fail UsageError, "unsupported HandBrakeCLI option name: #{name}" 258 | end 259 | 260 | @extra_options[name] = value 261 | end 262 | end 263 | 264 | def configure 265 | @audio_selections.uniq! 266 | @subtitle_selections.uniq! 267 | end 268 | 269 | def process_input(path) 270 | seconds = Time.now.tv_sec 271 | 272 | if @extra_options.include? 'scan' 273 | handbrake_command = [ 274 | 'HandBrakeCLI', 275 | '--input', path 276 | ] 277 | 278 | @extra_options.each do |name, value| 279 | handbrake_command << "--#{name}" 280 | handbrake_command << value unless value.nil? 281 | end 282 | 283 | system(*handbrake_command, exception: true) 284 | return 285 | end 286 | 287 | extension = '.mkv' 288 | 289 | if @extra_options.include? 'format' 290 | @extra_options.each do |name, value| 291 | if name == 'format' and not value.nil? 292 | extension = case value 293 | when 'av_mkv', 'av_mp4', 'av_webm' 294 | '.' + value.sub(/^av_/, '') 295 | else 296 | fail UsageError, "unsupported HandBrakeCLI format: #{value}" 297 | end 298 | end 299 | end 300 | end 301 | 302 | output = File.basename(path, '.*') + extension 303 | media_info = scan_media(path) 304 | video_options = get_video_options(media_info) 305 | audio_options = get_audio_options(media_info) 306 | subtitle_options = get_subtitle_options(media_info) 307 | 308 | handbrake_command = [ 309 | 'HandBrakeCLI', 310 | '--input', path, 311 | '--output', output, 312 | *video_options, 313 | *audio_options, 314 | *subtitle_options 315 | ] 316 | 317 | encoder_options = nil 318 | 319 | unless @extra_options.include? 'encoder' 320 | case @mode 321 | when :h264 322 | encoder_options = "vbv-maxrate=#{@vbv_size}:vbv-bufsize=#{@vbv_size}" 323 | when :nvenc_hevc 324 | encoder_options = 'spatial_aq=1:rc-lookahead=20' 325 | encoder_options += ':b_ref_mode=2' if @bframe_refs 326 | when :nvenc_av1 327 | encoder_options = 'spatial-aq=1:rc-lookahead=20' 328 | encoder_options += ':b_ref_mode=2' if @bframe_refs 329 | end 330 | end 331 | 332 | @extra_options.each do |name, value| 333 | handbrake_command << "--#{name}" 334 | 335 | if name == 'encopts' 336 | fail UsageError, "invalid HandBrakeCLI option usage: #{name}" if value.nil? 337 | 338 | handbrake_command << (encoder_options.nil? ? value : "#{encoder_options}:#{value}") 339 | encoder_options = nil 340 | else 341 | handbrake_command << value unless value.nil? 342 | end 343 | end 344 | 345 | handbrake_command += ['--encopts', encoder_options] unless encoder_options.nil? 346 | command_line = escape_command(handbrake_command) 347 | Kernel.warn 'Command line:' 348 | 349 | if @dry_run 350 | puts command_line 351 | return 352 | end 353 | 354 | Kernel.warn command_line 355 | fail "output file already exists: #{output}" if File.exist? output 356 | 357 | Kernel.warn 'Transcoding...' 358 | system(*handbrake_command, exception: true) 359 | Kernel.warn "\nElapsed time: #{seconds_to_time(Time.now.tv_sec - seconds)}\n\n" 360 | end 361 | 362 | def scan_media(path) 363 | Kernel.warn 'Scanning media...' 364 | media_info = '' 365 | 366 | IO.popen([ 367 | 'ffprobe', 368 | '-loglevel', 'quiet', 369 | '-show_streams', 370 | '-show_format', 371 | '-print_format', 'json', 372 | path 373 | ]) do |io| 374 | media_info = io.read 375 | end 376 | 377 | fail "scanning media failed: #{path}" unless $CHILD_STATUS.exitstatus == 0 378 | 379 | begin 380 | media_info = JSON.parse(media_info) 381 | rescue JSON::JSONError 382 | fail "media information not found: #{path}" 383 | end 384 | 385 | Kernel.warn media_info.inspect if @debug 386 | media_info 387 | end 388 | 389 | def escape_command(command) 390 | command_line = '' 391 | command.each {|item| command_line += "#{escape_string(item)} " } 392 | command_line.sub!(/ $/, '') 393 | command_line 394 | end 395 | 396 | def escape_string(str) 397 | # See: https://github.com/larskanis/shellwords 398 | return '""' if str.empty? 399 | 400 | str = str.dup 401 | 402 | if RUBY_PLATFORM =~ /mingw/ 403 | str.gsub!(/((?:\\)*)"/) { "\\" * ($1.length * 2) + "\\\"" } 404 | 405 | if str =~ /\s/ 406 | str.gsub!(/(\\+)\z/) { "\\" * ($1.length * 2 ) } 407 | str = "\"#{str}\"" 408 | end 409 | else 410 | str.gsub!(/([^A-Za-z0-9_\-.,:\/@\n])/, "\\\\\\1") 411 | str.gsub!(/\n/, "'\n'") 412 | end 413 | 414 | str 415 | end 416 | 417 | def seconds_to_time(seconds) 418 | sprintf("%02d:%02d:%02d", seconds / (60 * 60), (seconds / 60) % 60, seconds % 60) 419 | end 420 | 421 | def get_video_options(media_info) 422 | video = nil 423 | 424 | media_info['streams'].each do |stream| 425 | if stream['codec_type'] == 'video' 426 | video = stream 427 | break 428 | end 429 | end 430 | 431 | return [] if video.nil? 432 | 433 | options = [] 434 | preset = @preset 435 | bitrate = @bitrate 436 | quality = @quality 437 | 438 | unless @extra_options.include? 'encoder' 439 | encoder = nil 440 | quality = [[@quality.to_f, 1.0].max, 51.0].min.to_s.sub(/\.0$/, '') unless @quality.nil? 441 | 442 | case @mode 443 | when :h264 444 | encoder = 'x264' 445 | width = video['width'].to_i 446 | height = video['height'].to_i 447 | 448 | if width > 1280 or height > 720 449 | bitrate = 5000 450 | 451 | if width > 1920 or height > 1080 452 | options += [ 453 | '--maxWidth', '1920', 454 | '--maxHeight', '1080', 455 | '--loose-anamorphic' 456 | ] 457 | 458 | options += ['--colorspace', 'bt709'] if video.fetch('color_space', 'bt709') != 'bt709' 459 | end 460 | elsif width > 720 or height > 576 461 | bitrate = 2500 462 | else 463 | bitrate = 1250 464 | end 465 | 466 | @vbv_size = bitrate * 3 467 | 468 | if @quality.nil? 469 | bitrate = [[@bitrate.to_i, 470 | (bitrate * 0.8).to_i].max, 471 | (bitrate * 1.6).to_i].min unless @bitrate.nil? 472 | bitrate = bitrate.to_s 473 | options += ['--multi-pass', '--turbo'] 474 | else 475 | bitrate = nil 476 | end 477 | when :hevc 478 | encoder = 'x265_10bit' 479 | quality ||= '24' if @bitrate.nil? 480 | when :nvenc_hevc 481 | encoder = 'nvenc_h265_10bit' 482 | quality ||= '30' if @bitrate.nil? 483 | when :av1 484 | encoder = 'svt_av1_10bit' 485 | 486 | if @bitrate.nil? 487 | quality = @quality.nil? ? '30' : [[@quality.to_i, 0].max, 63].min.to_s 488 | end 489 | 490 | preset = @preset.nil? ? '8' : [[@preset.to_i, -1].max, 13].min.to_s 491 | when :nvenc_av1 492 | encoder = 'nvenc_av1_10bit' 493 | 494 | if @bitrate.nil? 495 | quality = @quality.nil? ? '37' : [[@quality.to_i, 0].max, 63].min.to_s 496 | end 497 | else 498 | quality = @quality 499 | end 500 | 501 | options += ['--encoder', encoder] unless encoder.nil? 502 | end 503 | 504 | options += ['--encoder-preset', preset] unless preset.nil? 505 | options += ['--vb', bitrate] unless bitrate.nil? 506 | options += ['--quality', quality] unless quality.nil? 507 | 508 | unless @extra_options.include? 'rate' or 509 | @extra_options.include? 'vfr' or 510 | @extra_options.include? 'cfr' or 511 | @extra_options.include? 'pfr' 512 | if video['codec_name'] == 'mpeg2video' and video['avg_frame_rate'] == '30000/1001' 513 | options += ['--rate', '29.97', '--cfr'] 514 | else 515 | options += ['--rate', '60'] 516 | end 517 | end 518 | 519 | options += ['--crop-mode', 'conservative'] unless @extra_options.include? 'crop' or 520 | @extra_options.include? 'crop-mode' 521 | options 522 | end 523 | 524 | def get_audio_options(media_info) 525 | return [] if @extra_options.include? 'audio' or 526 | @extra_options.include? 'all-audio' or 527 | @extra_options.include? 'first-audio' 528 | 529 | audio_tracks = [] 530 | 531 | @audio_selections.each do |selection| 532 | unless selection[:track].nil? 533 | index = 0 534 | 535 | media_info['streams'].each do |stream| 536 | next if stream['codec_type'] != 'audio' 537 | 538 | index += 1 539 | 540 | if index == selection[:track] 541 | audio_tracks += [{ 542 | :index => index, 543 | :stream => stream 544 | }] 545 | 546 | break 547 | end 548 | end 549 | end 550 | 551 | unless selection[:language].nil? 552 | index = 0 553 | 554 | media_info['streams'].each do |stream| 555 | next if stream['codec_type'] != 'audio' 556 | 557 | index += 1 558 | 559 | if selection[:language] == 'all' or 560 | stream.fetch('tags', {}).fetch('language', '') == selection[:language] 561 | audio_tracks += [{ 562 | :index => index, 563 | :stream => stream 564 | }] 565 | end 566 | end 567 | end 568 | 569 | unless selection[:title].nil? 570 | index = 0 571 | 572 | media_info['streams'].each do |stream| 573 | next if stream['codec_type'] != 'audio' 574 | 575 | index += 1 576 | 577 | if stream.fetch('tags', {}).fetch('title', '') =~ /#{selection[:title]}/i 578 | audio_tracks += [{ 579 | :index => index, 580 | :stream => stream 581 | }] 582 | end 583 | end 584 | end 585 | end 586 | 587 | audio_tracks.uniq! 588 | return [] if audio_tracks.empty? 589 | 590 | track_list = [] 591 | encoder_list = [] 592 | bitrate_list = [] 593 | mixdown_list = [] 594 | name_list = [] 595 | 596 | audio_tracks.each do |audio| 597 | track_list += [audio[:index].to_s] 598 | 599 | unless @extra_options.include? 'aencoder' 600 | channels = audio[:stream]['channels'].to_i 601 | codec_name = audio[:stream]['codec_name'] 602 | 603 | case @audio_mode 604 | when :aac 605 | if (codec_name == 'aac' and channels <= 6) or 606 | (@ac3_surround and codec_name == 'ac3' and channels > 2) 607 | encoder = 'copy' 608 | bitrate = '' 609 | mixdown = '' 610 | else 611 | encoder = @aac_encoder 612 | 613 | case channels 614 | when 1 615 | bitrate = '80' 616 | mixdown = 'mono' 617 | when 2 618 | bitrate = '128' 619 | mixdown = 'stereo' 620 | else 621 | if @ac3_surround 622 | encoder = 'ac3' 623 | bitrate = '448' 624 | else 625 | bitrate = '384' 626 | end 627 | 628 | mixdown = '5point1' 629 | end 630 | end 631 | when :opus 632 | if codec_name == 'opus' 633 | encoder = 'copy' 634 | bitrate = '' 635 | mixdown = '' 636 | else 637 | encoder = 'opus' 638 | 639 | case channels 640 | when 1 641 | bitrate = '64' 642 | mixdown = 'mono' 643 | when 2 644 | bitrate = '96' 645 | mixdown = 'stereo' 646 | else 647 | bitrate = '320' 648 | mixdown = '5point1' 649 | end 650 | end 651 | when :eac3 652 | if (codec_name =~ /ac3$/) or (codec_name == 'aac' and channels <= 6) 653 | encoder = 'copy' 654 | bitrate = '' 655 | mixdown = '' 656 | else 657 | encoder = 'eac3' 658 | 659 | case channels 660 | when 1 661 | bitrate = '96' 662 | mixdown = 'mono' 663 | when 2 664 | bitrate = '192' 665 | mixdown = 'stereo' 666 | else 667 | bitrate = '448' 668 | mixdown = '5point1' 669 | end 670 | end 671 | else 672 | encoder = '' 673 | bitrate = '' 674 | mixdown = '' 675 | end 676 | 677 | encoder_list += [encoder] 678 | bitrate_list += [bitrate] 679 | mixdown_list += [mixdown] 680 | end 681 | 682 | unless audio_tracks.count == 1 or @extra_options.include? 'aname' 683 | name_list += audio[:index] == 1 ? [''] : [audio[:stream].fetch('tags', {}).fetch('title', '').gsub(/,/, '","')] 684 | end 685 | end 686 | 687 | options = ['--audio', track_list.join(',')] 688 | 689 | unless @extra_options.include? 'aencoder' 690 | encoder_arg = encoder_list.join(',') 691 | options += ['--aencoder', encoder_arg] unless encoder_arg.empty? 692 | bitrate_arg = bitrate_list.join(',') 693 | options += ['--ab', bitrate_arg] unless bitrate_arg.empty? or @extra_options.include? 'ab' 694 | mixdown_arg = mixdown_list.join(',') 695 | options += ['--mixdown', mixdown_arg] unless mixdown_arg.empty? or @extra_options.include? 'mixdown' 696 | end 697 | 698 | unless audio_tracks.count == 1 or @extra_options.include? 'aname' 699 | options += ['--aname', name_list.join(',')] 700 | end 701 | 702 | options 703 | end 704 | 705 | def get_subtitle_options(media_info) 706 | return [] if @extra_options.include? 'subtitle' or 707 | @extra_options.include? 'all-subtitles' or 708 | @extra_options.include? 'first-subtitle' 709 | 710 | options = [] 711 | 712 | unless @burn_subtitle.nil? 713 | subtitle = nil 714 | index = 0 715 | 716 | media_info['streams'].each do |stream| 717 | next if stream['codec_type'] != 'subtitle' 718 | 719 | index += 1 720 | 721 | if @burn_subtitle == :auto 722 | if stream['codec_type'] == 'subtitle' and stream['disposition']['forced'] == 1 723 | subtitle = stream 724 | break 725 | end 726 | elsif index == @burn_subtitle 727 | subtitle = stream 728 | break 729 | end 730 | end 731 | 732 | return [] if subtitle.nil? 733 | 734 | options = ['--subtitle', index.to_s] 735 | 736 | if subtitle['codec_name'] == 'hdmv_pgs_subtitle' or subtitle['codec_name'] == 'dvd_subtitle' 737 | options += ['--subtitle-burned'] 738 | else 739 | options += ['--subtitle-default'] 740 | end 741 | end 742 | 743 | unless @subtitle_selections.empty? 744 | subtitle_tracks = [] 745 | index = 0 746 | 747 | media_info['streams'].each do |stream| 748 | next if stream['codec_type'] != 'subtitle' 749 | 750 | index += 1 751 | 752 | if stream['disposition']['forced'] == 1 753 | subtitle_tracks += [{ 754 | :index => index, 755 | :stream => stream 756 | }] 757 | 758 | break 759 | end 760 | end 761 | 762 | @subtitle_selections.each do |selection| 763 | unless selection[:track].nil? 764 | index = 0 765 | 766 | media_info['streams'].each do |stream| 767 | next if stream['codec_type'] != 'subtitle' 768 | 769 | index += 1 770 | 771 | if index == selection[:track] 772 | subtitle_tracks += [{ 773 | :index => index, 774 | :stream => stream 775 | }] 776 | 777 | break 778 | end 779 | end 780 | end 781 | 782 | unless selection[:language].nil? 783 | index = 0 784 | 785 | media_info['streams'].each do |stream| 786 | next if stream['codec_type'] != 'subtitle' 787 | 788 | index += 1 789 | 790 | if selection[:language] == 'all' or 791 | stream.fetch('tags', {}).fetch('language', '') == selection[:language] 792 | subtitle_tracks += [{ 793 | :index => index, 794 | :stream => stream 795 | }] 796 | end 797 | end 798 | end 799 | 800 | unless selection[:title].nil? 801 | index = 0 802 | 803 | media_info['streams'].each do |stream| 804 | next if stream['codec_type'] != 'subtitle' 805 | 806 | index += 1 807 | 808 | if stream.fetch('tags', {}).fetch('title', '') =~ /#{selection[:title]}/i 809 | subtitle_tracks += [{ 810 | :index => index, 811 | :stream => stream 812 | }] 813 | end 814 | end 815 | end 816 | end 817 | 818 | subtitle_tracks.uniq! 819 | return [] if subtitle_tracks.empty? 820 | 821 | track_list = [] 822 | default = nil 823 | name_list = [] 824 | 825 | subtitle_tracks.each do |subtitle| 826 | index = subtitle[:index].to_s 827 | track_list += [index] 828 | default ||= index if subtitle[:stream]['disposition']['forced'] == 1 829 | 830 | unless @extra_options.include? 'subname' 831 | name_list += [subtitle[:stream].fetch('tags', {}).fetch('title', '').gsub(/,/, '","')] 832 | end 833 | end 834 | 835 | options = ['--subtitle', track_list.join(',')] 836 | options += ['--subtitle-default', default] unless default.nil? 837 | 838 | unless @extra_options.include? 'subname' 839 | options += ['--subname', name_list.join(',')] 840 | end 841 | end 842 | 843 | options 844 | end 845 | end 846 | end 847 | 848 | Transcoding::Command.new.run 849 | --------------------------------------------------------------------------------