├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── common.py ├── prass.py ├── run-tests.py ├── setup.py ├── subs.py ├── tests ├── __init__.py ├── cleanup_script.ass ├── test_main.py ├── test_script.ass ├── test_subs.py └── test_tools.py └── tools.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | test 3 | *.pyc 4 | tests/media 5 | dist 6 | build 7 | tests.json 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.5" 5 | install: 6 | - pip install . 7 | script: 8 | - python run-tests.py 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Victor Efimov 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Prass [![Build Status](https://travis-ci.org/tp7/Prass.svg)](https://travis-ci.org/tp7/Prass) 2 | Console processor for ASS subtitles. 3 | 4 | Feature requests and any kind of feedback are welcome. 5 | 6 | ### Why? 7 | Imagine you've got a few dozens of subtitle files and you want to apply TPP to each of them, and maybe also restyle one of the files and use the same group of styles for everything else. This script allows you to easily do it from console so you don't have to suffer with Aegisub. 8 | 9 | ### Usage 10 | The main script is called `prass.py` and it provides a few commands for working with subtitles. 11 | ```bash 12 | # to convert subtitles from SRT to ASS 13 | prass convert-srt input.srt -o output.ass 14 | # to copy styles from one ASS script to another 15 | prass copy-styles --from template.ass --to input.ass -o output.ass 16 | # to sort an ASS script 17 | prass sort input.ass --by time -o output.ass 18 | # to run tpp 19 | prass tpp input.ass -s default,alt --lead-in 100 --lead-out 200 --keyframes kfs.txt --fps 23.976 --kf-before-start 150 --kf-after-start 150 20 | # to cleanup a script 21 | prass cleanup --comments --empty-lines --styles input.ass -o output.ass 22 | # to shift start time back by 1 minute and 10 seconds 23 | prass shift --start --by -1:10 input.ass -o output.ass 24 | ``` 25 | Some parameters are not mentioned - just run `prass --help` or `prass %command% --help` to see the full docs. 26 | 27 | ### Pipes 28 | Prass more or less supports pipes and allows you to do fun stuff like 29 | ```bash 30 | prass convert-srt input.srt | prass copy-styles --from template.ass | prass sort --by time | prass tpp --overlap 150 --gap 150 -o out.ass 31 | ``` 32 | If you don't provide some file arguments, most commands will use stdin/stdout by default. They will also do this if you provide "-" as file paths. 33 | 34 | ### Installation 35 | Prass should work on OS X, Linux and Windows without any problems, both on Python 2.7.x and Python 3.x (but py2 is preferable). Right now the only dependency is [Click](http://click.pocoo.org/3/). Assuming you have python and pip, just run: 36 | ```bash 37 | pip install git+https://github.com/tp7/prass 38 | ``` 39 | -------------------------------------------------------------------------------- /common.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from click.exceptions import ClickException 4 | 5 | class PrassError(ClickException): 6 | pass 7 | 8 | PY2 = sys.version_info[0] == 2 9 | 10 | if not PY2: 11 | py2_unicode_compatible = lambda x: x 12 | 13 | itervalues = lambda x: iter(x.values()) 14 | iteritems = lambda x: iter(x.items()) 15 | iterkeys = lambda x: iter(x.keys()) 16 | zip = zip 17 | map = map 18 | else: 19 | itervalues = lambda x: x.itervalues() 20 | iteritems = lambda x: x.iteritems() 21 | iterkeys = lambda x: x.iterkeys() 22 | import itertools 23 | zip = itertools.izip 24 | map = itertools.imap 25 | 26 | def py2_unicode_compatible(cls): 27 | cls.__unicode__ = cls.__str__ 28 | cls.__str__ = lambda x: x.__unicode__().encode("utf-8") 29 | return cls 30 | -------------------------------------------------------------------------------- /prass.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | import click 3 | import sys 4 | from operator import attrgetter 5 | from common import PrassError, zip, map 6 | from subs import AssScript 7 | from tools import Timecodes, parse_keyframes 8 | 9 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 10 | 11 | 12 | def parse_fps_string(fps_string): 13 | if '/' in fps_string: 14 | parts = fps_string.split('/') 15 | if len(parts) > 2: 16 | raise PrassError('Invalid fps value') 17 | try: 18 | return float(parts[0]) / float(parts[1]) 19 | except ValueError: 20 | raise PrassError('Invalid fps value') 21 | else: 22 | try: 23 | return float(fps_string) 24 | except ValueError: 25 | raise PrassError('Invalid fps value') 26 | 27 | 28 | def parse_shift_string(shift_string): 29 | try: 30 | if ':' in shift_string: 31 | negator = 1 32 | if shift_string.startswith('-'): 33 | negator = -1 34 | shift_string = shift_string[1:] 35 | parts = list(map(float, shift_string.split(':'))) 36 | if len(parts) > 3: 37 | raise PrassError("Invalid shift value: '{0}'".format(shift_string)) 38 | shift_seconds = sum(part * multiplier for part, multiplier in zip(reversed(parts), (1.0, 60.0, 3600.0))) 39 | return shift_seconds * 1000 * negator # convert to ms 40 | else: 41 | if shift_string.endswith("ms"): 42 | return float(shift_string[:-2]) 43 | elif shift_string.endswith("s"): 44 | return float(shift_string[:-1]) * 1000 45 | else: 46 | return float(shift_string) * 1000 47 | except ValueError: 48 | raise PrassError("Invalid shift value: '{0}'".format(shift_string)) 49 | 50 | 51 | def parse_resolution_string(resolution_string): 52 | if resolution_string == '720p': 53 | return 1280,720 54 | if resolution_string == '1080p': 55 | return 1920,1080 56 | for separator in (':', 'x', ","): 57 | if separator in resolution_string: 58 | width, _, height = resolution_string.partition(separator) 59 | try: 60 | return int(width), int(height) 61 | except ValueError: 62 | raise PrassError("Invalid resolution string: '{0}'".format(resolution_string)) 63 | raise PrassError("Invalid resolution string: '{0}'".format(resolution_string)) 64 | 65 | 66 | @click.group(context_settings=CONTEXT_SETTINGS) 67 | def cli(): 68 | pass 69 | 70 | 71 | @cli.command("convert-srt", short_help="convert srt subtitles to ass") 72 | @click.option("-o", "--output", "output_file", default='-', type=click.File(encoding="utf-8-sig", mode='w')) 73 | @click.option("--encoding", "encoding", default='utf-8-sig', help="Encoding to use for the input SRT file") 74 | @click.argument("input_path", type=click.Path(exists=True, dir_okay=False, allow_dash=True)) 75 | def convert_srt(input_path, output_file, encoding): 76 | """Convert SRT script to ASS. 77 | 78 | \b 79 | Example: 80 | $ prass convert-srt input.srt -o output.ass --encoding cp1251 81 | """ 82 | try: 83 | with click.open_file(input_path, encoding=encoding) as input_file: 84 | AssScript.from_srt_stream(input_file).to_ass_stream(output_file) 85 | except LookupError: 86 | raise PrassError("Encoding {0} doesn't exist".format(encoding)) 87 | 88 | 89 | @cli.command('copy-styles', short_help="copy styles from one ass script to another") 90 | @click.option("-o", "--output", "output_file", default="-", type=click.File(encoding="utf-8-sig", mode='w')) 91 | @click.option('--to', 'dst_file', required=True, type=click.File(encoding='utf-8-sig', mode='r'), 92 | help="File to copy the styles to") 93 | @click.option('--from', 'src_file', required=True, type=click.File(encoding='utf-8-sig', mode='r'), 94 | help="File to take the styles from") 95 | @click.option('--clean', default=False, is_flag=True, 96 | help="Remove all older styles in the destination file") 97 | @click.option('--resample/--no-resample', 'resample', default=True, 98 | help="Resample style resolution to match output script when possible") 99 | @click.option('--resolution', 'forced_resolution', default=None, help="Assume resolution of the destination file") 100 | def copy_styles(dst_file, src_file, output_file, clean, resample, forced_resolution): 101 | """Copy styles from one ASS script to another, write the result as a third script. 102 | You always have to provide the "from" argument, "to" defaults to stdin and "output" defaults to stdout. 103 | 104 | \b 105 | Simple usage: 106 | $ prass copy-styles --from template.ass --to unstyled.ass -o styled.ass 107 | With pipes: 108 | $ cat unstyled.ass | prass copy-styles --from template.ass | prass cleanup --comments -o out.ass 109 | """ 110 | src_script = AssScript.from_ass_stream(src_file) 111 | dst_script = AssScript.from_ass_stream(dst_file) 112 | if forced_resolution: 113 | forced_resolution = parse_resolution_string(forced_resolution) 114 | 115 | dst_script.append_styles(src_script, clean, resample, forced_resolution) 116 | dst_script.to_ass_stream(output_file) 117 | 118 | 119 | @cli.command('sort', short_help="sort ass script events") 120 | @click.option("-o", "--output", "output_file", default='-', type=click.File(encoding="utf-8-sig", mode='w'), metavar="") 121 | @click.argument("input_file", type=click.File(encoding="utf-8-sig")) 122 | @click.option('--by', 'sort_by', multiple=True, default=['start'], help="Parameter to sort by", 123 | type=click.Choice(['time', 'start', 'end', 'style', 'actor', 'effect', 'layer'])) 124 | @click.option('--desc', 'descending', default=False, is_flag=True, help="Descending order") 125 | def sort_script(input_file, output_file, sort_by, descending): 126 | """Sort script by one or more parameters. 127 | 128 | \b 129 | Sorting by time: 130 | $ prass sort input.ass --by time -o output.ass 131 | Sorting by time and then by layer, both in descending order: 132 | $ prass sort input.ass --by time --by layer --desc -o output.ass 133 | 134 | """ 135 | script = AssScript.from_ass_stream(input_file) 136 | attrs_map = { 137 | "start": "start", 138 | "time": "start", 139 | "end": "end", 140 | "style": "style", 141 | "actor": "actor", 142 | "effect": "effect", 143 | "layer": "layer" 144 | } 145 | getter = attrgetter(*[attrs_map[x] for x in sort_by]) 146 | script.sort_events(getter, descending) 147 | script.to_ass_stream(output_file) 148 | 149 | 150 | @cli.command('tpp', short_help="timing post-processor") 151 | @click.option("-o", "--output", "output_file", default='-', type=click.File(encoding="utf-8-sig", mode='w'), metavar="") 152 | @click.argument("input_file", type=click.File(encoding="utf-8-sig")) 153 | @click.option("-s", "--style", "styles", multiple=True, metavar="", 154 | help="Style names to process. All by default. Use comma to separate, or supply it multiple times") 155 | @click.option("--lead-in", "lead_in", default=0, type=int, metavar="", 156 | help="Lead-in value in milliseconds") 157 | @click.option("--lead-out", "lead_out", default=0, type=int, metavar="", 158 | help="Lead-out value in milliseconds") 159 | @click.option("--overlap", "max_overlap", default=0, type=int, metavar="", 160 | help="Maximum overlap for two lines to be made continuous, in milliseconds") 161 | @click.option("--gap", "max_gap", default=0, type=int, metavar="", 162 | help="Maximum gap between two lines to be made continuous, in milliseconds") 163 | @click.option("--bias", "adjacent_bias", default=50, type=click.IntRange(0, 100), metavar="", 164 | help="How to set the adjoining of lines. " 165 | "0 - change start time of the second line, 100 - end time of the first line. " 166 | "Values from 0 to 100 allowed.") 167 | @click.option("--keyframes", "keyframes_path", type=click.Path(exists=True, readable=True, dir_okay=False), metavar="", 168 | help="Path to keyframes file") 169 | @click.option("--timecodes", "timecodes_path", type=click.Path(readable=True, dir_okay=False), metavar="", 170 | help="Path to timecodes file") 171 | @click.option("--fps", "fps", metavar="", 172 | help="Fps provided as decimal or proper fraction, in case you don't have timecodes") 173 | @click.option("--kf-before-start", default=0, type=float, metavar="", 174 | help="Max distance between a keyframe and event start for it to be snapped, when keyframe is placed before the event") 175 | @click.option("--kf-after-start", default=0, type=float, metavar="", 176 | help="Max distance between a keyframe and event start for it to be snapped, when keyframe is placed after the start time") 177 | @click.option("--kf-before-end", default=0, type=float, metavar="", 178 | help="Max distance between a keyframe and event end for it to be snapped, when keyframe is placed before the end time") 179 | @click.option("--kf-after-end", default=0, type=float, metavar="", 180 | help="Max distance between a keyframe and event end for it to be snapped, when keyframe is placed after the event") 181 | def tpp(input_file, output_file, styles, lead_in, lead_out, max_overlap, max_gap, adjacent_bias, 182 | keyframes_path, timecodes_path, fps, kf_before_start, kf_after_start, kf_before_end, kf_after_end): 183 | """Timing post-processor. 184 | It's a pretty straightforward port from Aegisub so you should be familiar with it. 185 | You have to specify keyframes and timecodes (either as a CFR value or a timecodes file) if you want keyframe snapping. 186 | All parameters default to zero so if you don't want something - just don't put it in the command line. 187 | 188 | \b 189 | To add lead-in and lead-out: 190 | $ prass tpp input.ass --lead-in 50 --lead-out 150 -o output.ass 191 | To make adjacent lines continuous, with 80% bias to changing end time of the first line: 192 | $ prass tpp input.ass --overlap 50 --gap 200 --bias 80 -o output.ass 193 | To snap events to keyframes without a timecodes file: 194 | $ prass tpp input.ass --keyframes kfs.txt --fps 23.976 --kf-before-end 150 --kf-after-end 150 --kf-before-start 150 --kf-after-start 150 -o output.ass 195 | """ 196 | 197 | if fps and timecodes_path: 198 | raise PrassError('Timecodes file and fps cannot be specified at the same time') 199 | if fps: 200 | timecodes = Timecodes.cfr(parse_fps_string(fps)) 201 | elif timecodes_path: 202 | timecodes = Timecodes.from_file(timecodes_path) 203 | elif any((kf_before_start, kf_after_start, kf_before_end, kf_after_end)): 204 | raise PrassError('You have to provide either fps or timecodes file for keyframes processing') 205 | else: 206 | timecodes = None 207 | 208 | if timecodes and not keyframes_path: 209 | raise PrassError('You have to specify keyframes file for keyframes processing') 210 | 211 | keyframes_list = parse_keyframes(keyframes_path) if keyframes_path else None 212 | 213 | actual_styles = [] 214 | for style in styles: 215 | actual_styles.extend(x.strip() for x in style.split(',')) 216 | 217 | script = AssScript.from_ass_stream(input_file) 218 | script.tpp(actual_styles, lead_in, lead_out, max_overlap, max_gap, adjacent_bias, 219 | keyframes_list, timecodes, kf_before_start, kf_after_start, kf_before_end, kf_after_end) 220 | script.to_ass_stream(output_file) 221 | 222 | 223 | @cli.command("cleanup", short_help="remove useless data from ass scripts") 224 | @click.option("-o", "--output", "output_file", default='-', type=click.File(encoding="utf-8-sig", mode='w'), metavar="") 225 | @click.argument("input_file", type=click.File(encoding="utf-8-sig")) 226 | @click.option("--comments", "drop_comments", default=False, is_flag=True, 227 | help="Remove commented lines") 228 | @click.option("--empty-lines", "drop_empty_lines", default=False, is_flag=True, 229 | help="Remove empty lines") 230 | @click.option("--styles", "drop_unused_styles", default=False, is_flag=True, 231 | help="Remove unused styles") 232 | @click.option("--actors", "drop_actors", default=False, is_flag=True, 233 | help="Remove actor field") 234 | @click.option("--effects", "drop_effects", default=False, is_flag=True, 235 | help="Remove effects field") 236 | @click.option("--spacing", "drop_spacing", default=False, is_flag=True, 237 | help="Removes double spacing and newlines") 238 | @click.option("--sections", "drop_sections", type=click.Choice(["fonts", "graphics", "aegi", "extradata"]), multiple=True, 239 | help="Remove optional sections from the script") 240 | def cleanup(input_file, output_file, drop_comments, drop_empty_lines, drop_unused_styles, 241 | drop_actors, drop_effects, drop_spacing, drop_sections): 242 | """Remove junk data from ASS script 243 | 244 | \b 245 | To remove commented and empty lines plus clear unused styles: 246 | $ prass cleanup input.ass --comments --empty-lines --styles output.ass 247 | """ 248 | sections_map = { 249 | "fonts": "[Fonts]", 250 | "graphics": "[Graphics]", 251 | "aegi": "[Aegisub Project Garbage]", 252 | "extradata": "[Aegisub Extradata]" 253 | } 254 | drop_sections = [sections_map[x] for x in drop_sections] 255 | 256 | script = AssScript.from_ass_stream(input_file) 257 | script.cleanup(drop_comments, drop_empty_lines, drop_unused_styles, drop_actors, drop_effects, drop_spacing, drop_sections) 258 | script.to_ass_stream(output_file) 259 | 260 | 261 | @cli.command("shift", short_help="shift start or end times of every event") 262 | @click.option("-o", "--output", "output_file", default='-', type=click.File(encoding="utf-8-sig", mode='w'), metavar="") 263 | @click.argument("input_file", type=click.File(encoding="utf-8-sig")) 264 | @click.option("--by", "shift_by", required=False, default="0", metavar="