Using FS mtime to reload non recursive cache.
[pyTivo.git] / plugins / video / transcode.py
blobf310f187646952d5f6a802fe3160e906573e7dfb
1 import subprocess, shutil, os, re, sys, ConfigParser, time, lrucache, math
2 import config
3 import logging
5 logger = logging.getLogger('pyTivo.video.transcode')
7 info_cache = lrucache.LRUCache(1000)
8 videotest = os.path.join(os.path.dirname(__file__), 'videotest.mpg')
10 BAD_MPEG_FPS = ['15.00']
12 def ffmpeg_path():
13 return config.get('Server', 'ffmpeg')
15 # XXX BIG HACK
16 # subprocess is broken for me on windows so super hack
17 def patchSubprocess():
18 o = subprocess.Popen._make_inheritable
20 def _make_inheritable(self, handle):
21 if not handle: return subprocess.GetCurrentProcess()
22 return o(self, handle)
24 subprocess.Popen._make_inheritable = _make_inheritable
25 mswindows = (sys.platform == "win32")
26 if mswindows:
27 patchSubprocess()
29 def output_video(inFile, outFile, tsn=''):
30 if tivo_compatable(inFile, tsn):
31 logger.debug('%s is tivo compatible' % inFile)
32 f = file(inFile, 'rb')
33 shutil.copyfileobj(f, outFile)
34 f.close()
35 else:
36 logger.debug('%s is not tivo compatible' % inFile)
37 transcode(inFile, outFile, tsn)
39 def transcode(inFile, outFile, tsn=''):
41 settings = {}
42 settings['video_codec'] = select_videocodec(tsn)
43 settings['video_br'] = select_videobr(tsn)
44 settings['video_fps'] = select_videofps(inFile, tsn)
45 settings['max_video_br'] = select_maxvideobr()
46 settings['buff_size'] = select_buffsize()
47 settings['aspect_ratio'] = ' '.join(select_aspect(inFile, tsn))
48 settings['audio_br'] = select_audiobr(tsn)
49 settings['audio_fr'] = select_audiofr(inFile, tsn)
50 settings['audio_ch'] = select_audioch(tsn)
51 settings['audio_codec'] = select_audiocodec(inFile, tsn)
52 settings['ffmpeg_pram'] = select_ffmpegprams(tsn)
53 settings['format'] = select_format(tsn)
55 cmd_string = config.getFFmpegTemplate(tsn) % settings
57 cmd = [ffmpeg_path(), '-i', inFile] + cmd_string.split()
58 logging.debug('transcoding to tivo model '+tsn[:3]+' using ffmpeg command:')
59 logging.debug(' '.join(cmd))
60 ffmpeg = subprocess.Popen(cmd, stdout=subprocess.PIPE)
61 try:
62 shutil.copyfileobj(ffmpeg.stdout, outFile)
63 except:
64 kill(ffmpeg.pid)
66 def select_audiocodec(inFile, tsn = ''):
67 # Default, compatible with all TiVo's
68 codec = 'ac3'
69 if config.getAudioCodec(tsn) == None:
70 type, width, height, fps, millisecs, kbps, akbps, acodec, afreq, vpar = video_info(inFile)
71 if acodec in ('ac3', 'liba52', 'mp2'):
72 if akbps == None:
73 cmd_string = '-y -vcodec mpeg2video -r 29.97 -b 1000k -acodec copy -t 00:00:01 -f vob -'
74 if video_check(inFile, cmd_string):
75 type, width, height, fps, millisecs, kbps, akbps, acodec, afreq, vpar = video_info(videotest)
76 if not akbps == None and int(akbps) <= config.getMaxAudioBR(tsn):
77 # compatible codec and bitrate, do not reencode audio
78 codec = 'copy'
79 else:
80 codec = config.getAudioCodec(tsn)
81 return '-acodec '+codec
83 def select_audiofr(inFile, tsn):
84 freq = '48000' #default
85 type, width, height, fps, millisecs, kbps, akbps, acodec, afreq, vpar = video_info(inFile)
86 if not afreq == None and afreq in ('44100', '48000'):
87 # compatible frequency
88 freq = afreq
89 if config.getAudioFR(tsn) != None:
90 freq = config.getAudioFR(tsn)
91 return '-ar '+freq
93 def select_audioch(tsn):
94 if config.getAudioCH(tsn) != None:
95 return '-ac '+config.getAudioCH(tsn)
96 return ''
98 def select_videofps(inFile, tsn):
99 type, width, height, fps, millisecs, kbps, akbps, acodec, afreq, vpar = video_info(inFile)
100 vfps = '-r 29.97' #default
101 if config.isHDtivo(tsn) and fps not in BAD_MPEG_FPS:
102 vfps = ' '
103 if config.getVideoFPS(tsn) != None:
104 vfps = '-r '+config.getVideoFPS(tsn)
105 return vfps
107 def select_videocodec(tsn):
108 vcodec = 'mpeg2video' #default
109 if config.getVideoCodec(tsn) != None:
110 vcodec = config.getVideoCodec(tsn)
111 return '-vcodec '+vcodec
113 def select_videobr(tsn):
114 return '-b '+config.getVideoBR(tsn)
116 def select_audiobr(tsn):
117 return '-ab '+config.getAudioBR(tsn)
119 def select_maxvideobr():
120 return '-maxrate '+config.getMaxVideoBR()
122 def select_buffsize():
123 return '-bufsize '+config.getBuffSize()
125 def select_ffmpegprams(tsn):
126 if config.getFFmpegPrams(tsn) != None:
127 return config.getFFmpegPrams(tsn)
128 return ''
130 def select_format(tsn):
131 fmt = 'vob'
132 if config.getFormat(tsn) != None:
133 fmt = config.getFormat(tsn)
134 return '-f '+fmt+' -'
136 def select_aspect(inFile, tsn = ''):
137 TIVO_WIDTH = config.getTivoWidth(tsn)
138 TIVO_HEIGHT = config.getTivoHeight(tsn)
140 type, width, height, fps, millisecs, kbps, akbps, acodec, afreq, vpar = video_info(inFile)
142 logging.debug('tsn: %s' % tsn)
144 aspect169 = config.get169Setting(tsn)
146 logging.debug('aspect169:%s' % aspect169)
148 optres = config.getOptres(tsn)
150 logging.debug('optres:%s' % optres)
152 if optres:
153 optHeight = config.nearestTivoHeight(height)
154 optWidth = config.nearestTivoWidth(width)
155 if optHeight < TIVO_HEIGHT:
156 TIVO_HEIGHT = optHeight
157 if optWidth < TIVO_WIDTH:
158 TIVO_WIDTH = optWidth
160 d = gcd(height,width)
161 ratio = (width*100)/height
162 rheight, rwidth = height/d, width/d
164 logger.debug('File=%s Type=%s width=%s height=%s fps=%s millisecs=%s ratio=%s rheight=%s rwidth=%s TIVO_HEIGHT=%sTIVO_WIDTH=%s' % (inFile, type, width, height, fps, millisecs, ratio, rheight, rwidth, TIVO_HEIGHT, TIVO_WIDTH))
166 multiplier16by9 = (16.0 * TIVO_HEIGHT) / (9.0 * TIVO_WIDTH)
167 multiplier4by3 = (4.0 * TIVO_HEIGHT) / (3.0 * TIVO_WIDTH)
169 if config.isHDtivo(tsn) and optres:
170 if config.getPixelAR(0):
171 if vpar == None:
172 npar = config.getPixelAR(1)
173 else:
174 npar = vpar
175 # adjust for pixel aspect ratio, if set, because TiVo expects square pixels
176 if npar<1.0:
177 return ['-s', str(width) + 'x' + str(int(math.ceil(height/npar)))]
178 elif npar>1.0:
179 # FFMPEG expects width to be a multiple of two
180 return ['-s', str(int(math.ceil(width*npar/2.0)*2)) + 'x' + str(height)]
181 if height <= TIVO_HEIGHT:
182 # pass all resolutions to S3, except heights greater than conf height
183 return []
184 # else, resize video.
185 if (rwidth, rheight) in [(4, 3), (10, 11), (15, 11), (59, 54), (59, 72), (59, 36), (59, 54)]:
186 logger.debug('File is within 4:3 list.')
187 return ['-aspect', '4:3', '-s', str(TIVO_WIDTH) + 'x' + str(TIVO_HEIGHT)]
188 elif ((rwidth, rheight) in [(16, 9), (20, 11), (40, 33), (118, 81), (59, 27)]) and aspect169:
189 logger.debug('File is within 16:9 list and 16:9 allowed.')
190 return ['-aspect', '16:9', '-s', str(TIVO_WIDTH) + 'x' + str(TIVO_HEIGHT)]
191 else:
192 settings = []
193 #If video is wider than 4:3 add top and bottom padding
194 if (ratio > 133): #Might be 16:9 file, or just need padding on top and bottom
195 if aspect169 and (ratio > 135): #If file would fall in 4:3 assume it is supposed to be 4:3
196 if (ratio > 177):#too short needs padding top and bottom
197 endHeight = int(((TIVO_WIDTH*height)/width) * multiplier16by9)
198 settings.append('-aspect')
199 settings.append('16:9')
200 if endHeight % 2:
201 endHeight -= 1
202 if endHeight < TIVO_HEIGHT * 0.99:
203 settings.append('-s')
204 settings.append(str(TIVO_WIDTH) + 'x' + str(endHeight))
206 topPadding = ((TIVO_HEIGHT - endHeight)/2)
207 if topPadding % 2:
208 topPadding -= 1
210 settings.append('-padtop')
211 settings.append(str(topPadding))
212 bottomPadding = (TIVO_HEIGHT - endHeight) - topPadding
213 settings.append('-padbottom')
214 settings.append(str(bottomPadding))
215 else: #if only very small amount of padding needed, then just stretch it
216 settings.append('-s')
217 settings.append(str(TIVO_WIDTH) + 'x' + str(TIVO_HEIGHT))
218 logger.debug('16:9 aspect allowed, file is wider than 16:9 padding top and bottom\n%s' % ' '.join(settings))
219 else: #too skinny needs padding on left and right.
220 endWidth = int((TIVO_HEIGHT*width)/(height*multiplier16by9))
221 settings.append('-aspect')
222 settings.append('16:9')
223 if endWidth % 2:
224 endWidth -= 1
225 if endWidth < (TIVO_WIDTH-10):
226 settings.append('-s')
227 settings.append(str(endWidth) + 'x' + str(TIVO_HEIGHT))
229 leftPadding = ((TIVO_WIDTH - endWidth)/2)
230 if leftPadding % 2:
231 leftPadding -= 1
233 settings.append('-padleft')
234 settings.append(str(leftPadding))
235 rightPadding = (TIVO_WIDTH - endWidth) - leftPadding
236 settings.append('-padright')
237 settings.append(str(rightPadding))
238 else: #if only very small amount of padding needed, then just stretch it
239 settings.append('-s')
240 settings.append(str(TIVO_WIDTH) + 'x' + str(TIVO_HEIGHT))
241 logger.debug('16:9 aspect allowed, file is narrower than 16:9 padding left and right\n%s' % ' '.join(settings))
242 else: #this is a 4:3 file or 16:9 output not allowed
243 settings.append('-aspect')
244 settings.append('4:3')
245 endHeight = int(((TIVO_WIDTH*height)/width) * multiplier4by3)
246 if endHeight % 2:
247 endHeight -= 1
248 if endHeight < TIVO_HEIGHT * 0.99:
249 settings.append('-s')
250 settings.append(str(TIVO_WIDTH) + 'x' + str(endHeight))
252 topPadding = ((TIVO_HEIGHT - endHeight)/2)
253 if topPadding % 2:
254 topPadding -= 1
256 settings.append('-padtop')
257 settings.append(str(topPadding))
258 bottomPadding = (TIVO_HEIGHT - endHeight) - topPadding
259 settings.append('-padbottom')
260 settings.append(str(bottomPadding))
261 else: #if only very small amount of padding needed, then just stretch it
262 settings.append('-s')
263 settings.append(str(TIVO_WIDTH) + 'x' + str(TIVO_HEIGHT))
264 logging.debug('File is wider than 4:3 padding top and bottom\n%s' % ' '.join(settings))
266 return settings
267 #If video is taller than 4:3 add left and right padding, this is rare. All of these files will always be sent in
268 #an aspect ratio of 4:3 since they are so narrow.
269 else:
270 endWidth = int((TIVO_HEIGHT*width)/(height*multiplier4by3))
271 settings.append('-aspect')
272 settings.append('4:3')
273 if endWidth % 2:
274 endWidth -= 1
275 if endWidth < (TIVO_WIDTH * 0.99):
276 settings.append('-s')
277 settings.append(str(endWidth) + 'x' + str(TIVO_HEIGHT))
279 leftPadding = ((TIVO_WIDTH - endWidth)/2)
280 if leftPadding % 2:
281 leftPadding -= 1
283 settings.append('-padleft')
284 settings.append(str(leftPadding))
285 rightPadding = (TIVO_WIDTH - endWidth) - leftPadding
286 settings.append('-padright')
287 settings.append(str(rightPadding))
288 else: #if only very small amount of padding needed, then just stretch it
289 settings.append('-s')
290 settings.append(str(TIVO_WIDTH) + 'x' + str(TIVO_HEIGHT))
292 logger.debug_write('File is taller than 4:3 padding left and right\n%s' % ' '.join(settings))
294 return settings
296 def tivo_compatable(inFile, tsn = ''):
297 supportedModes = [[720, 480], [704, 480], [544, 480], [480, 480], [352, 480]]
298 type, width, height, fps, millisecs, kbps, akbps, acodec, afreq, vpar = video_info(inFile)
299 #print type, width, height, fps, millisecs, kbps, akbps, acodec
301 if (inFile[-5:]).lower() == '.tivo':
302 logger.debug('TRUE, ends with .tivo. %s' % inFile)
303 return True
305 if not type == 'mpeg2video':
306 #print 'Not Tivo Codec'
307 logger.debug('FALSE, type %s not mpeg2video. %s' % (type, inFile))
308 return False
310 if os.path.splitext(inFile)[-1].lower() in ('.ts', '.mpv'):
311 logger.debug('FALSE, ext %s not tivo compatible. %s' % (os.path.splitext(inFile)[-1], inFile))
312 return False
314 if acodec == 'dca':
315 logger.debug('FALSE, acodec %s not supported. %s' % (acodec, inFile))
316 return False
318 if acodec != None:
319 if not akbps or int(akbps) > config.getMaxAudioBR(tsn):
320 logger.debug('FALSE, %s kbps exceeds max audio bitrate. %s' % (akbps, inFile))
321 return False
323 if kbps != None:
324 abit = max('0', akbps)
325 if int(kbps)-int(abit) > config.strtod(config.getMaxVideoBR())/1000:
326 logger.debug('FALSE, %s kbps exceeds max video bitrate. %s' % (kbps, inFile))
327 return False
328 else:
329 logger.debug('FALSE, %s kbps not supported. %s' % (kbps, inFile))
330 return False
332 if config.isHDtivo(tsn):
333 if vpar != 1.0:
334 if config.getPixelAR(0):
335 if vpar != None or config.getPixelAR(1) != 1.0:
336 logger.debug('FALSE, %s not correct PAR, %s' % (vpar, inFile))
337 return False
338 logger.debug('TRUE, HD Tivo detected, skipping remaining tests %s' % inFile)
339 return True
341 if not fps == '29.97':
342 #print 'Not Tivo fps'
343 logger.debug('FALSE, %s fps, should be 29.97. %s' % (fps, inFile))
344 return False
346 for mode in supportedModes:
347 if (mode[0], mode[1]) == (width, height):
348 logger.debug('TRUE, %s x %s is valid. %s' % (width, height, inFile))
349 return True
350 #print 'Not Tivo dimensions'
351 logger.debug('FALSE, %s x %s not in supported modes. %s' % (width, height, inFile))
352 return False
354 def video_info(inFile):
355 mtime = os.stat(inFile).st_mtime
356 if inFile != videotest:
357 if inFile in info_cache and info_cache[inFile][0] == mtime:
358 logging.debug('CACHE HIT! %s' % inFile)
359 return info_cache[inFile][1]
361 if (inFile[-5:]).lower() == '.tivo':
362 info_cache[inFile] = (mtime, (True, True, True, True, True, True, True, True, True, True))
363 logger.debug('VALID, ends in .tivo. %s' % inFile)
364 return True, True, True, True, True, True, True, True, True, True
366 cmd = [ffmpeg_path(), '-i', inFile ]
367 ffmpeg = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE, stdin=subprocess.PIPE)
369 # wait 10 sec if ffmpeg is not back give up
370 for i in xrange(200):
371 time.sleep(.05)
372 if not ffmpeg.poll() == None:
373 break
375 if ffmpeg.poll() == None:
376 kill(ffmpeg.pid)
377 info_cache[inFile] = (mtime, (None, None, None, None, None, None, None, None, None, None))
378 return None, None, None, None, None, None, None, None, None, None
380 output = ffmpeg.stderr.read()
381 logging.debug('ffmpeg output=%s' % output)
383 rezre = re.compile(r'.*Video: ([^,]+),.*')
384 x = rezre.search(output)
385 if x:
386 codec = x.group(1)
387 else:
388 info_cache[inFile] = (mtime, (None, None, None, None, None, None, None, None, None, None))
389 logging.debug('failed at video codec')
390 return None, None, None, None, None, None, None, None, None, None
392 rezre = re.compile(r'.*Video: .+, (\d+)x(\d+)[, ].*')
393 x = rezre.search(output)
394 if x:
395 width = int(x.group(1))
396 height = int(x.group(2))
397 else:
398 info_cache[inFile] = (mtime, (None, None, None, None, None, None, None, None, None, None))
399 logger.debug('failed at width/height')
400 return None, None, None, None, None, None, None, None, None, None
402 rezre = re.compile(r'.*Video: .+, (.+) (?:fps|tb).*')
403 x = rezre.search(output)
404 if x:
405 fps = x.group(1)
406 else:
407 info_cache[inFile] = (mtime, (None, None, None, None, None, None, None, None, None, None))
408 logging.debug('failed at fps')
409 return None, None, None, None, None, None, None, None, None, None
411 # Allow override only if it is mpeg2 and frame rate was doubled to 59.94
412 if (not fps == '29.97') and (codec == 'mpeg2video'):
413 # First look for the build 7215 version
414 rezre = re.compile(r'.*film source: 29.97.*')
415 x = rezre.search(output.lower() )
416 if x:
417 logger.debug('film source: 29.97 setting fps to 29.97')
418 fps = '29.97'
419 else:
420 # for build 8047:
421 rezre = re.compile(r'.*frame rate differs from container frame rate: 29.97.*')
422 logger.debug('Bug in VideoReDo')
423 x = rezre.search(output.lower() )
424 if x:
425 fps = '29.97'
427 durre = re.compile(r'.*Duration: (.{2}):(.{2}):(.{2})\.(.),')
428 d = durre.search(output)
429 if d:
430 millisecs = ((int(d.group(1))*3600) + (int(d.group(2))*60) + int(d.group(3)))*1000 + (int(d.group(4))*100)
431 else:
432 millisecs = 0
434 #get bitrate of source for tivo compatibility test.
435 rezre = re.compile(r'.*bitrate: (.+) (?:kb/s).*')
436 x = rezre.search(output)
437 if x:
438 kbps = x.group(1)
439 else:
440 kbps = None
441 logger.debug('failed at kbps')
443 #get audio bitrate of source for tivo compatibility test.
444 rezre = re.compile(r'.*Audio: .+, (.+) (?:kb/s).*')
445 x = rezre.search(output)
446 if x:
447 akbps = x.group(1)
448 else:
449 akbps = None
450 logger.debug('failed at akbps')
452 #get audio codec of source for tivo compatibility test.
453 rezre = re.compile(r'.*Audio: ([^,]+),.*')
454 x = rezre.search(output)
455 if x:
456 acodec = x.group(1)
457 else:
458 acodec = None
459 logger.debug('failed at acodec')
461 #get audio frequency of source for tivo compatibility test.
462 rezre = re.compile(r'.*Audio: .+, (.+) (?:Hz).*')
463 x = rezre.search(output)
464 if x:
465 afreq = x.group(1)
466 else:
467 afreq = None
468 logger.debug('failed at afreq')
470 #get par.
471 rezre = re.compile(r'.*Video: .+PAR ([0-9]+):([0-9]+) DAR [0-9:]+.*')
472 x = rezre.search(output)
473 if x and x.group(1)!="0" and x.group(2)!="0":
474 vpar = float(x.group(1))/float(x.group(2))
475 else:
476 vpar = None
478 info_cache[inFile] = (mtime, (codec, width, height, fps, millisecs, kbps, akbps, acodec, afreq, vpar))
479 logger.debug('Codec=%s width=%s height=%s fps=%s millisecs=%s kbps=%s akbps=%s acodec=%s afreq=%s par=%s' %
480 (codec, width, height, fps, millisecs, kbps, akbps, acodec, afreq, vpar))
481 return codec, width, height, fps, millisecs, kbps, akbps, acodec, afreq, vpar
483 def video_check(inFile, cmd_string):
484 cmd = [ffmpeg_path(), '-i', inFile] + cmd_string.split()
485 ffmpeg = subprocess.Popen(cmd, stdout=subprocess.PIPE)
486 try:
487 shutil.copyfileobj(ffmpeg.stdout, open(videotest, 'wb'))
488 return True
489 except:
490 kill(ffmpeg.pid)
491 return False
493 def supported_format(inFile):
494 if video_info(inFile)[0]:
495 return True
496 else:
497 logger.debug('FALSE, file not supported %s' % inFile)
498 return False
500 def kill(pid):
501 logger.debug('killing pid=%s' % str(pid))
502 if mswindows:
503 win32kill(pid)
504 else:
505 import os, signal
506 os.kill(pid, signal.SIGTERM)
508 def win32kill(pid):
509 import ctypes
510 handle = ctypes.windll.kernel32.OpenProcess(1, False, pid)
511 ctypes.windll.kernel32.TerminateProcess(handle, -1)
512 ctypes.windll.kernel32.CloseHandle(handle)
514 def gcd(a,b):
515 while b:
516 a, b = b, a % b
517 return a