├── .gitignore ├── LICENSE ├── README ├── chapparse.py ├── tcconv.py ├── templates.py ├── test ├── amkvc.mod.txt ├── amkvc │ ├── amkv.qpfile │ ├── amkv.xml │ ├── amkvc.txt │ └── amkvtags.xml ├── audio.flac ├── chnames.txt ├── lobster │ ├── PC5Ep3_ordchp.txt │ ├── hcpc12.txt │ ├── lobster.qpf │ ├── lobster.txt │ └── lobster.xml ├── tc1-cfr.txt ├── tc1-vfr.txt ├── tc2-cfr.txt ├── tc2-vfr.txt ├── test.avs ├── test.py ├── test2.avs └── test2.vfr.txt └── vfr.py /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.pyc 3 | *.diff 4 | *.converted.txt 5 | *.mkv 6 | *.mka 7 | *.ass 8 | *.ffindex 9 | *.xml 10 | *.qpfile 11 | *.qpf 12 | Thumbs.db 13 | *.bak 14 | .project 15 | .pydevproject 16 | .settings 17 | test/result* 18 | test/chap* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010 Ricardo Constantino 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | vfr.py 2 | ====== 3 | 4 | Inspired on: Daiz's AutoMKVChapters, TheFluff's split_aud, BD_Chapters 5 | 6 | Needs: Python 3; MkvToolNix (for audio trimming) 7 | 8 | What it does 9 | ------------ 10 | 11 | * Reads the first line of uncommented Trims from an .avs; 12 | * Uses timecodes files to get each trim's frame's timestamp; 13 | * Offsets the trims accordingly; 14 | * Creates a basic xml with Matroska chapters, x264 chapters if ending in 'x264.txt' or OGM chapters if any other extension is used; 15 | * Creates a qpfile to use with x264; 16 | * Cuts and merges audio (as per split_aud, only using v2 timecodes instead of expecting cfr) (all options work as split_aud); 17 | * No longer needs tcConv but converts v1 timecodes to v2 internally; 18 | * If requested, can output v2 timecodes from v1 and fps parsing. If --ofps is being used, v2 timecodes will use it; 19 | * Can output a qpfile with converted frames meant to be used for an ivtc'd encode using non-ivtc'd frames (feature inspired by automkvchapters) (not completely accurate, obviously); 20 | * Using FFmpegsource's CorrectNTSCRationalFramerate, this is actually more precise in the v2 timecodes it produces than tcConv; 21 | * Accepts AutoMKVChapters-like templates. 22 | 23 | Only the .avs with trims is required for vfr.py to run. You can use -v and/or --test to debug the script. All other options and arguments are optional. 24 | 25 | Usage 26 | ----- 27 | 28 | vfr.py -i audio.aac -o audio.cut.mka -f 30/1.001 -l tRim -c chapters.xml -t template.txt \ 29 | -n chnames.txt -q qpfile.qpf -vmr --ofps 24/1.001 --timecodes v2.txt --test trims.avs outtrims.avs 30 | 31 | Required: 32 | trims.avs = Gets first uncommented line starting with trims from this Avisynth script 33 | 34 | Optional: 35 | -i = Audio to be cut (takes whatever mkvmerge takes) 36 | -o = Cut audio inside .mka 37 | Default: input.cut.mka 38 | -d = Manually set delay time for input audio (can be negative) 39 | -b = Reverse parsing of .avs (from bottom to top) 40 | -f = Frames per second or timecodes file if vfr input 41 | (takes "25", "24000/1001", "30000:1001", "24/1.001" and "30:1.001" as cfr input) 42 | Default: 30000/1001 43 | -l = Look for a line starting with a case-sensitive trim() or case-insensitive comment succeeding the trims, interpreted as a regular expression. 44 | Default: case insensitive trim 45 | -g = Specify directly the line used 46 | -c = Chapters file. If extension is 'xml', outputs MKV Chapters; 47 | if extension is 'x264.txt', outputs x264 Chapters; else, outputs OGM Chapters 48 | -n = Text file with chapter names, one per line; assumed to be UTF-8 without BOM 49 | -q = QPFile for use in x264; will use --ofps frames 50 | -t = Template file for advanced Matroska chapters 51 | -v = Verbose mode 52 | -m = Merge split audio files 53 | -r = Remove split audio files after merging 54 | --clip = Only pick trims that are using this clip name. Ex: ClipX.Trim(0,1) or Trim(ClipX,0,1) 55 | --uid = Set base UID for --template/--chnames 56 | --chnames = Path to basic text containing chapter titles separated by newlines 57 | --ofps = Output FPS (used in qpfile, v2 timecodes and avs export) 58 | Default: -f 59 | --timecodes = Output v2 timecodes (from fps and v1 parsing) (if using --ofps, outputs v2 timecodes using this) 60 | --sbr = Set this if inputting an .aac and it's SBR/HE-AAC 61 | --test = Test Mode (doesn't create new files) 62 | outtrims.avs = If chapparse.py is present, outputs .avs with offset and converted trims 63 | 64 | To do: 65 | * Optimize code and/or improve its legibility 66 | 67 | Known issues: 68 | * Conversion from a different input fps to output fps is not accurate (probably no way it can ever be fixed) 69 | -------------------------------------------------------------------------------- /chapparse.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # chapparse.py 3 | import sys, re, getopt, os 4 | from string import Template 5 | 6 | name = 'chapparse.py' 7 | version = '0.4' 8 | rat = re.compile('(\d+)(?:/|:)(\d+)') 9 | chapre = re.compile("CHAPTER\d+=(\S+)",re.I) 10 | x264 = 'x264-64' 11 | ffmpeg = 'ffmpeg' 12 | mkvmerge = 'mkvmerge' 13 | avs2yuv = 'avs2yuv' 14 | timeCodes = frameNumbers = merge = [] 15 | 16 | def main(): 17 | try: 18 | opts, args = getopt.getopt(sys.argv[1:], "i:o:f:b:e:s:a:x:c:hmr",['help','avs=','test','x264opts=']) 19 | except getopt.GetoptError as err: 20 | print(err) 21 | help() 22 | sys.exit() 23 | 24 | set = dict(input='video.mkv',output='',audio='',index='', 25 | fps='24000/1001',batch='',method='x264',resize='',avs='',mergeFiles=False,removeFiles=False, 26 | x264opts='--preset placebo --crf 16 --level 41 --rc-lookahead 250',test=False, 27 | x264=x264,ffmpeg=ffmpeg,mkvmerge=mkvmerge,avs2yuv=avs2yuv,chapters='chapters.txt',crop='0,0,0,0') 28 | 29 | for o, v in opts: 30 | if o == '-i': 31 | set['input'] = v 32 | elif o == '-o': 33 | set['output'] = v[:-4] 34 | elif o == '-f': 35 | set['fps'] = v 36 | elif o == '-b': 37 | set['batch'] = v 38 | elif o == '-e': 39 | set['method'] = v 40 | elif o == '-s': 41 | set['resize'] = v 42 | elif o == '-c': 43 | set['crop'] = v 44 | elif o in ('-x','--x264opts'): 45 | set['x264opts'] = v 46 | elif o == '-a': 47 | set['audio'] = v 48 | elif o in ('-h','--help'): 49 | help() 50 | sys.exit() 51 | elif o == '-m': 52 | set['mergeFiles'] = True 53 | elif o == '-r': 54 | set['removeFiles'] = True 55 | elif o == '--avs': 56 | set['avs'] = v 57 | elif o == '--test': 58 | set['test'] = True 59 | else: 60 | assert False, "unhandled option" 61 | 62 | set['chapters'] = set['chapters'] if len(args) != 1 else args[0] 63 | 64 | if set['output'] == '': 65 | set['output'] = set['input'][:-4]+'.encode' 66 | if set['avs'] == '' and set['method'] == 'avisynth': 67 | set['avs'] = set['output']+'.avs' 68 | if set['avs'] != '' and set['method'] == 'x264': 69 | set['method'] = 'avisynth' 70 | if set['batch'] == '': 71 | set['batch'] = set['output']+'.bat' 72 | if os.path.isfile(set['chapters']) != True: 73 | print("You must set a valid OGM chapters file.") 74 | sys.exit(2) 75 | 76 | if set['test'] == True: 77 | for key in sorted(set): 78 | print(key.ljust(8),'=',set[key]) 79 | print() 80 | 81 | timeStrings = parseOgm(args[0]) 82 | 83 | timeCodes = [time2ms(timeString) for timeString in timeStrings] 84 | 85 | frameNumbers = [ms2frame(timeCode,set['fps']) for timeCode in timeCodes] 86 | 87 | set['cmd'] = Template('${piper}"${x264}" ${x264opts} --demuxer y4m${end} - -o "${output}-part${part}.mkv"') 88 | 89 | if set['method'] == 'avisynth': 90 | set['avs'] = '"%s"' % set['avs'] 91 | if set['test'] == False: 92 | set = writeAvisynth(set,frameNumbers) 93 | else: 94 | print('Writing avisynth script') 95 | elif set['method'] == 'ffmpeg': 96 | set['resize'] = ' -s '+set['resize'] if (set['method'] == 'ffmpeg' and set['resize'] != '') else '' 97 | elif set['method'] == 'x264': 98 | set['cmd'] = Template('"${x264}" ${x264opts}${seek}${end} $xinput -o "${output}-part${part}.mkv"') 99 | set['index'] = '"%s.x264.ffindex"' % set['input'] if set['input'][-3:] in ('mkv','mp4','wmv') else '' 100 | set['xinput'] = '"%s" --index %s' % (set['input'],set['index']) if set['index'] != '' else '"%s"' % set['input'] 101 | x264crop = 'crop:'+set['crop'] if (set['method'] == 'x264' and set['crop'] != '0,0,0,0') else '' 102 | x264resize='resize:'+','.join(set['resize'].split('x')) if (set['method'] == 'x264' and set['resize'] != '') else '' 103 | sep = '/' if (x264crop != '' and x264resize != '') else '' 104 | set['x264opts'] = set['x264opts']+' --vf %s%s%s' % (x264crop,sep,x264resize) if (x264crop != '' or x264resize != '') else set['x264opts'] 105 | 106 | writeBatch(set,frameNumbers,timeStrings) 107 | 108 | def help(): 109 | print(""" 110 | %s %s 111 | Usage: chapparse.py [options] chapters.txt 112 | chapters.txt is an OGM chapters file to get chapter points from whence to 113 | separate the encodes 114 | 115 | Options: 116 | -i video.mkv 117 | Video to be encoded 118 | -o encode.mkv 119 | Encoded video 120 | -f 24000/1001 121 | Frames per second 122 | -s 1280x720 123 | Resolution to resize to (no default) 124 | -e x264 125 | Method of resizing [avisynth,ffmpeg,x264] 126 | -a audio.m4a 127 | Audio to mux in the final file 128 | -b encode.bat 129 | Batch file with the instructions for chapter-separated encode 130 | -x "--preset placebo --crf 16 --level 41 --rc-lookahead 250", --x264opts 131 | x264 options (don't use --demuxer, --input, --output or --frames) 132 | --avs encode.avs 133 | If using avisynth method 134 | -m 135 | Merge parts 136 | -r 137 | Remove extra files 138 | -h, --help 139 | This help file""" % (name,version)) 140 | 141 | def time2ms(ts): 142 | 143 | t = ts.split(':') 144 | h = int(t[0]) * 3600000 145 | m = h + int(t[1]) * 60000 146 | ms = round(m + float(t[2]) * 1000) 147 | 148 | return ms 149 | 150 | def ms2frame(ms,fps): 151 | 152 | s = ms / 1000 153 | fps = rat.search(fps).groups() if rat.search(fps) else \ 154 | [re.search('(\d+)',fps).group(0),'1'] 155 | frame = round((int(fps[0])/int(fps[1])) * s) 156 | 157 | return frame 158 | 159 | def parseOgm(file): 160 | 161 | timeStrings = [] 162 | 163 | with open(file) as chapFile: 164 | for line in chapFile: 165 | timeString = chapre.match(line) 166 | if timeString != None: 167 | timeStrings.append(timeString.group(1)) 168 | 169 | return timeStrings 170 | 171 | def writeAvisynth(set,frameNumbers): 172 | # needs dict with 'avs', 'input', 'resize' (if needed) and list with frameNumbers 173 | if os.path.isfile(set['avs'][1:-1]) != True: 174 | with open(set['avs'][1:-1],'w') as avs: 175 | if set['input'][:-4] in ('.mkv','.wmv','.mp4'): 176 | avs.write('FFVideoSource("%s")\n' % set['input']) 177 | elif set['input'][:-4] == '.avi': 178 | avs.write('AviSource("%s")\n' % set['input']) 179 | elif set['input'] != '': 180 | avs.write('DirectShowSource("%s")\n' % set['input']) 181 | if set['resize'] != '': 182 | avs.write('Spline36Resize(%s)\n' % ','.join(set['resize'].split('x'))) 183 | avs.write('+'.join(['Trim(%d,%d)' % (frameNumbers[i],frameNumbers[i+1]-1) for i in range(len(frameNumbers)-1)])) 184 | avs.write('+Trim(%d,0)\n' % frameNumbers[-1]) 185 | else: 186 | with open(set['avs'][1:-1],'a') as avs: 187 | avs.write('\n') 188 | avs.write('+'.join(['Trim(%d,%d)' % (frameNumbers[i],frameNumbers[i+1]-1) for i in range(len(frameNumbers)-1)])) 189 | avs.write('+Trim(%d,0)\n' % frameNumbers[-1]) 190 | 191 | set['resize'] = '' 192 | if set['input'][:-3] in ('mkv','wmv','mp4'): 193 | set['index'] = '"%s.mkv.ffindex"' % set['output'] 194 | 195 | return set 196 | 197 | def cmdMake(set,frameNumbers,timeStrings,i): 198 | begin = frameNumbers[i] 199 | frames = frameNumbers[i+1]-begin if i != len(frameNumbers)-1 else 0 200 | 201 | if set['method'] == 'avisynth': 202 | set['seek'] = ' -seek %d' % begin 203 | elif set['method'] == 'ffmpeg': 204 | set['seek'] = ' -ss %s' % timeStrings[i] 205 | elif set['method'] == 'x264': 206 | set['seek'] = ' --seek %d' % begin 207 | if frames != 0: 208 | if set['method'] == 'avisynth': 209 | set['frames'] = ' -frames %d' % frames 210 | elif set['method'] == 'ffmpeg': 211 | set['frames'] = ' -vframes %d' % frames 212 | elif set['method'] == 'x264': 213 | set['frames'] = '' 214 | set['end'] = ' --frames %d' % frames 215 | else: 216 | set['end'] = set['frames'] = '' 217 | 218 | set['merge'] = '"%s-part%d.mkv"' % (set['output'],i+1) 219 | 220 | set['part'] = i+1 221 | 222 | if set['method'] == 'avisynth': 223 | set['piper'] = Template('"${avs2yuv}"${seek}${frames} $avs -o - | ') 224 | elif set['method'] == 'ffmpeg': 225 | set['piper'] = Template('"${ffmpeg}" -i "${input}"${resize}${seek}${frames} -f yuv4mpegpipe -sws_fags spline - | ') 226 | 227 | if set['method'] in ('avisynth','ffmpeg'): 228 | set['piper'] = set['piper'].substitute(set) 229 | 230 | return set 231 | 232 | def writeBatch(set,frameNumbers,timeStrings): 233 | if set['test'] == False: 234 | with open(set['batch'],'w') as batch: 235 | merge = [] 236 | if os.name == 'posix': 237 | batch.write('#!/bin/sh\n\n') 238 | for i in range(len(frameNumbers)): 239 | set2 = cmdMake(set,frameNumbers,timeStrings,i) 240 | batch.write(set['cmd'].substitute(set2)+'\n') 241 | merge.append(set2['merge']) 242 | 243 | if set['mergeFiles'] == True: 244 | batch.write('\n"%s" -o "%s" %s --default-duration "1:%sfps"' % (set['mkvmerge'],set['output']+'.mkv',' +'.join(merge),set['fps'])) 245 | if set['audio'] != '': 246 | batch.write(' -D --no-chapters "%s"' % set['audio']) 247 | batch.write(' --chapters "%s"' % set['chapters']) 248 | batch.write('\n') 249 | rem = ' '.join(merge) 250 | if set['removeFiles'] == True and os.name == 'nt': 251 | batch.write('del %s' % rem) 252 | elif set['removeFiles'] == True and os.name == 'posix': 253 | batch.write('rm %s' % rem) 254 | else: 255 | print('Writing batch file') 256 | #print('Example:',set['cmd'].format(cmdMake(set,frameNumbers,timeStrings,3))) 257 | 258 | if __name__ == '__main__': 259 | if len(sys.argv) > 1: 260 | main() 261 | else: 262 | print('Usage: chapparse.py [options] chapters.txt') 263 | sys.exit() -------------------------------------------------------------------------------- /tcconv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from sys import argv 4 | try: 5 | from vfr import parse_tc 6 | except ImportError: 7 | exit("tcconv requires vfr.py in order to work") 8 | 9 | if len(argv) >= 4: 10 | fps = argv[1] 11 | tc = argv[2] 12 | frames = int(argv[3]) 13 | first = int(argv[4]) if len(argv) == 5 else 0 14 | parse_tc(fps, frames, tc, first) 15 | else: 16 | exit("tcconv.py []") 17 | -------------------------------------------------------------------------------- /templates.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import unicode_literals 4 | from io import open 5 | 6 | class AutoMKVChapters: 7 | class Template: 8 | def __init__(self): 9 | from random import randint 10 | self.uid = randint(10**4,10**6) 11 | self.num_editions = 1 12 | self.lang = ['eng'] 13 | self.country = ['us'] 14 | self.fps = '30' 15 | self.ofps = '24' 16 | self.qpf = '0' 17 | self.idr = False 18 | self.trims = None 19 | self.kframes = None 20 | 21 | def toxml(self,chapfile): 22 | 23 | chf = open(chapfile+'.xml','w',encoding='utf-8') 24 | head = '\n\n' 25 | chf.write(head+'\n') 26 | 27 | if self.num_editions > 1: 28 | tagf = open(chapfile+'tags.xml','w') 29 | tagf.write(head+'\n') 30 | else: 31 | tagf = False 32 | 33 | for ed in self.editions: 34 | chf.write('\t\n') 35 | chf.write('\t\t{0:d}\n'.format(ed.hidden) if ed.hidden else '') 36 | chf.write('\t\t{0:d}\n'.format(ed.default) if ed.default else '') 37 | chf.write('\t\t{0:d}\n'.format(ed.ordered) if ed.ordered else '') 38 | chf.write('\t\t{0:d}\n'.format(ed.uid)) 39 | 40 | if tagf: 41 | tagf.write('\t\n\t\t\n') 42 | tagf.write('\t\t\t{0:d}\n'.format(ed.uid)) 43 | tagf.write('\t\t\t50\n\t\t\n') 44 | num_names = len(ed.name) if len(ed.name) < len(self.lang) else len(self.lang) 45 | for i in range(num_names): 46 | tagf.write('\t\t\n\t\t\tTITLE\n') 47 | tagf.write('\t\t\t{0}\n'.format(ed.name[i] if ed.name[i] != '' else ed.name[i-1])) 48 | tagf.write('\t\t\t{0}\n'.format(self.lang[i])) 49 | tagf.write('\t\t\t{0:d}\n'.format(1 if i == 0 else 0)) 50 | tagf.write('\t\t\n') 51 | tagf.write('\t\n') 52 | 53 | for ch in ed.chapters: 54 | chf.write('\t\t\n') 55 | num_names = len(ch.name) if len(ch.name) < len(self.lang) else len(self.lang) 56 | for i in range(num_names): 57 | chf.write('\t\t\t\n') 58 | chf.write('\t\t\t\t{0}\n'.format(ch.name[i] if ch.name[i] != '' else ch.name[i-1])) 59 | chf.write('\t\t\t\t{0}\n'.format(self.lang[i]) if self.lang[i] != 'eng' else '') 60 | chf.write('\t\t\t\t{0}\n'.format(self.country[i]) if i < len(self.country) else '') 61 | chf.write('\t\t\t\n') 62 | chf.write('\t\t\t{0:d}\n'.format(ch.uid)) 63 | chf.write('\t\t\t{0}\n'.format(ch.start)) 64 | chf.write('\t\t\t{0}\n'.format(ch.end) if ch.end else '') 65 | chf.write('\t\t\t{0:d}\n'.format(ch.hidden) if ch.hidden != 0 else '') 66 | chf.write('\t\t\t{0:d}\n'.format(ch.enabled) if ch.enabled != 1 else '') 67 | chf.write('\t\t\t{0}\n'.format(ch.suid) if ch.suid else '') 68 | chf.write('\t\t\n') 69 | 70 | chf.write('\t\n') 71 | 72 | chf.write('\n') 73 | 74 | if tagf: 75 | tagf.write('\n') 76 | tagf.close() 77 | 78 | if self.qpf != '0' and self.kframes: 79 | from vfr import write_qpfile 80 | if self.qpf != '1': 81 | qpfile = self.qpf 82 | else: 83 | qpfile = chapfile+'.qpfile' 84 | write_qpfile(qpfile, self.kframes, self.idr) 85 | 86 | def connect_with_vfr(self,avs,label=None,clip=None): 87 | """ 88 | Connects templates.py with vfr.py, enabling its use outside of vfr.py. 89 | 90 | Uses the same quirks as AMkvC but only for 24 and 30 fps. 91 | Ex: inputfps=30 is understood as being '30*1000/1001' 92 | 93 | """ 94 | 95 | from vfr import parse_trims, fmt_time 96 | 97 | # compensate for amkvc's fps assumption 98 | if self.fps in ('24','30'): 99 | fps = self.fps + '/1.001' 100 | else: 101 | fps = str(self.fps) 102 | if self.ofps and self.ofps in ('24','30'): 103 | ofps = self.ofps + '/1.001' 104 | else: 105 | ofps = str(self.ofps) 106 | 107 | Trims2, Trims2ts = parse_trims(avs, fps, ofps, label=label, clip=clip)[2:4] 108 | Trims2ts = [(fmt_time(i[0]),fmt_time(i[1]) if i[1] != 0 else None) for i in Trims2ts] 109 | 110 | self.trims = Trims2ts 111 | self.kframes = Trims2 112 | 113 | def parse_mkv(self, path): 114 | """Parse a Matroska file for SegmentUID and Duration""" 115 | import binascii 116 | import struct 117 | 118 | def get_data_len(byte): 119 | """Get the length (bytes) of the element data""" 120 | n = ord(byte) 121 | mask = 0b10000000 122 | while not n & mask: 123 | mask >>= 1 124 | return n & ~mask 125 | 126 | suid = tcscale = duration = 0 127 | with open(path, 'rb') as file: 128 | if file.read(4) != b'\x1A\x45\xDF\xA3': # not a Matroska file 129 | return suid, duration 130 | chunk_size = 100000 # 100 kB 131 | i = 0 132 | while True: 133 | if suid and tcscale and duration: 134 | break 135 | bin = file.read(chunk_size) 136 | if not bin: 137 | break 138 | suid_pos = bin.find(b'\x73\xA4\x90') # \x90 -> 16 bytes 139 | if suid_pos != -1: 140 | suid_pos = 4 + i * chunk_size + suid_pos + 3 141 | file.seek(suid_pos) 142 | suid = binascii.hexlify(file.read(16)).decode() 143 | tcscale_pos = bin.find(b'\x2A\xD7\xB1') 144 | if tcscale_pos != -1: 145 | tcscale_pos = 4 + i * chunk_size + tcscale_pos + 3 146 | file.seek(tcscale_pos) 147 | tcscale_len = get_data_len(file.read(1)) 148 | tcscale = int(binascii.hexlify(file.read(tcscale_len)), 16) 149 | duration_pos = bin.find(b'\x44\x89\x84') # float (4 bytes) 150 | if duration_pos != -1: 151 | duration_pos = 4 + i * chunk_size + duration_pos + 3 152 | file.seek(duration_pos) 153 | duration = struct.unpack('>f', file.read(4))[0] 154 | if not duration: # double (8 bytes) 155 | duration_pos = bin.find(b'\x44\x89\x88') 156 | if duration_pos != -1: 157 | duration_pos = 4 + i * chunk_size + duration_pos + 3 158 | file.seek(duration_pos) 159 | duration = struct.unpack('>d', file.read(8))[0] 160 | if bin.find(b'\x1F\x43\xB6\x75') != -1: 161 | # segment info should be before the clusters 162 | break 163 | i += 1 164 | duration = duration * tcscale / 1000000 165 | return suid, duration 166 | 167 | class Edition: 168 | def __init__(self): 169 | self.default = 0 170 | self.name = ['Default'] 171 | self.hidden = 0 172 | self.ordered = 0 173 | self.num_chapters = 1 174 | self.uid = 0 175 | 176 | class Chapter: 177 | def __init__(self): 178 | self.name = ['Chapter'] 179 | self.chapter = False 180 | self.start = False 181 | self.end = False 182 | self.suid = False 183 | self.hidden = 0 184 | self.uid = 0 185 | self.enabled = 1 186 | 187 | def __init__(self, templatefile, output=None, avs=None, trims=None, 188 | kframes=None, uid=None, label=None, ifps=None, clip=None, 189 | idr=False): 190 | try: 191 | import configparser 192 | except ImportError: 193 | import ConfigParser as configparser 194 | from io import open 195 | 196 | # Init config 197 | config = configparser.ConfigParser() 198 | template = open(templatefile, encoding='utf-8') 199 | 200 | # Read template 201 | config.readfp(template) 202 | template.close() 203 | 204 | # Template defaults 205 | self = self.Template() 206 | self.editions = [] 207 | self.uid = uid if uid else self.uid 208 | 209 | # Set mkvinfo path 210 | from vfr import mkvmerge, parse_with_mkvmerge, fmt_time 211 | from os.path import dirname, join, isfile 212 | 213 | # Set placeholder for mkvinfo output 214 | mkv_globbed = False 215 | mkvinfo = {} 216 | 217 | for k, v in config.items('info'): 218 | if k == 'lang': 219 | self.lang = v.split(',') 220 | elif k == 'country': 221 | self.country = v.split(',') 222 | elif k == 'inputfps': 223 | self.fps = v 224 | elif k == 'outputfps': 225 | self.ofps = v 226 | elif k == 'createqpfile': 227 | self.qpf = v 228 | elif k == 'uid': 229 | self.uid = int(v) 230 | elif k == 'editions': 231 | self.num_editions = int(v) 232 | 233 | if avs and not ifps: 234 | self.connect_with_vfr(avs, label, clip) 235 | elif trims: 236 | self.trims = trims 237 | self.kframes = kframes 238 | else: 239 | self.trims = False 240 | self.idr = idr 241 | 242 | for i in range(self.num_editions): 243 | from re import compile 244 | ed = self.Edition() 245 | ed.uid = self.uid * 100 246 | self.uid += 1 247 | cuid = ed.uid 248 | ed.num = i+1 249 | ed.chapters = [] 250 | stuff = {} 251 | 252 | for k, v in config.items('edition{0:d}'.format(ed.num)): 253 | if k == 'default': 254 | ed.default = int(v) 255 | elif k == 'name': 256 | ed.name = v.split(',') 257 | elif k == 'ordered': 258 | ed.ordered = int(v) 259 | elif k == 'hidden': 260 | ed.hidden = int(v) 261 | elif k == 'chapters': 262 | ed.num_chapters = int(v) 263 | for i in range(ed.num_chapters): 264 | stuff[i+1] = [] 265 | elif k == 'uid': 266 | ed.uid = int(v) 267 | else: 268 | opt_re = compile('(\d+)(\w+)') 269 | ret = opt_re.search(k) 270 | if ret: 271 | stuff[int(ret.group(1))].append((ret.group(2),v)) 272 | 273 | for j in range(ed.num_chapters): 274 | ch = self.Chapter() 275 | cuid += 1 276 | ch.uid = cuid 277 | ch.num = j+1 278 | 279 | for k, v in stuff[j+1]: 280 | if k == 'name': 281 | ch.name = v.split(',') 282 | elif k == 'chapter': 283 | ch.chapter = int(v) 284 | elif k == 'start': 285 | ch.start = v 286 | elif k == 'end': 287 | ch.end = v 288 | elif k == 'suid': 289 | ch.suid = v.strip() if ret else 0 290 | elif k == 'hidden': 291 | ch.hidden = int(v) 292 | elif k == 'enabled': 293 | ch.enabled = int(v) 294 | 295 | if ch.suid and not isfile(ch.suid): 296 | ch.suid = ch.suid.replace('0x','').lower().replace(' ','') 297 | 298 | if ch.chapter and not (ch.start and ch.end): 299 | ch.start, ch.end = self.trims[ch.chapter-1] if self.trims else (ch.start, ch.end) 300 | elif ch.suid: 301 | mkvfiles = [] 302 | if isfile(ch.suid): 303 | mkvfiles = [ch.suid] 304 | elif not mkv_globbed: 305 | from glob import glob 306 | mkvfiles = glob('*.mkv') + glob(join(dirname(avs),'*.mkv')) 307 | mkv_globbed = True 308 | if mkvfiles: 309 | if parse_with_mkvmerge: 310 | from subprocess import check_output 311 | import json 312 | for file in mkvfiles: 313 | info = check_output([mkvmerge, '-i', '-F', 'json', 314 | '--output-charset', 'utf-8', file]).decode('utf-8') 315 | try: 316 | props = json.loads(info).get("container", {}).get("properties", {}) 317 | ch.suid = props.get("segment_uid", 0) 318 | duration = props.get("duration", 0) 319 | mkvinfo[ch.suid] = {'file': file, 320 | 'duration': fmt_time(duration * 10**6) 321 | if duration else 0} 322 | except Exception: 323 | pass 324 | else: 325 | for file in mkvfiles: 326 | ch.suid, duration = self.parse_mkv(file) 327 | mkvinfo[ch.suid] = {'file': file, 328 | 'duration': fmt_time(duration * 10**6) 329 | if duration else 0} 330 | if not (ch.start or ch.end): 331 | ch.start = fmt_time(0) if not ch.start else ch.start 332 | ch.end = mkvinfo[ch.suid]['duration'] if not ch.end and (ch.suid in mkvinfo) else ch.end 333 | 334 | ed.chapters.append(ch) 335 | self.editions.append(ed) 336 | if output: 337 | self.toxml(output) 338 | 339 | 340 | def main(args): 341 | 342 | template = args[0] 343 | output = args[1] 344 | avs = args[2] if len(args) == 3 else None 345 | 346 | chaps = AutoMKVChapters(template,output,avs) 347 | 348 | if __name__ == '__main__': 349 | from sys import argv, exit 350 | if len(argv) > 1: 351 | main(argv[1:]) 352 | else: 353 | exit("templates.py