converter.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. #!/usr/bin/python
  2. # -*- coding: utf-8 -*-
  3. import json
  4. import logging
  5. import os
  6. import re
  7. import shutil
  8. from lvc import resources, settings, utils
  9. from lvc.utils import hms_to_seconds
  10. from lvc.qtfaststart import processor
  11. from lvc.qtfaststart.exceptions import FastStartException
  12. logger = logging.getLogger(__name__)
  13. NON_WORD_CHARS = re.compile(r"[^a-zA-Z0-9]+")
  14. class ConverterInfo(object):
  15. """Describes a particular output converter
  16. ConverterInfo is the base class for all converters. Subclasses must
  17. implement get_executable() and get_arguments()
  18. :attribue name: user-friendly name for this converter
  19. :attribute identifier: unique id for this converter
  20. :attribute width: output width for this converter, or None to copy the
  21. input width. This attribute is set to a default on construction, but can
  22. be changed to reflect the user overriding the default.
  23. :attribute height: output height for this converter. Works just like
  24. width
  25. :attribute dont_upsize: should we allow upsizing for conversions?
  26. """
  27. media_type = None
  28. bitrate = None
  29. extension = None
  30. audio_only = False
  31. def __init__(self, name, width=None, height=None, dont_upsize=True):
  32. self.name = name
  33. self.identifier = NON_WORD_CHARS.sub("", name).lower()
  34. self.width = width
  35. self.height = height
  36. self.dont_upsize = dont_upsize
  37. def get_executable(self):
  38. raise NotImplementedError
  39. def get_arguments(self, video, output):
  40. raise NotImplementedError
  41. def get_output_filename(self, video):
  42. basename = os.path.basename(video.filename)
  43. name, ext = os.path.splitext(basename)
  44. if ext and ext[0] == '.':
  45. ext = ext[1:]
  46. extension = self.extension if self.extension else ext
  47. return '%s_%s.%s' % (name, self.identifier, extension)
  48. def get_output_size_guess(self, video):
  49. if not self.bitrate or not video.duration:
  50. return None
  51. if video.duration:
  52. return self.bitrate * video.duration / 8
  53. def finalize(self, temp_output, output):
  54. err = None
  55. needs_remove = False
  56. if self.media_type == 'format' and self.extension == 'mp4':
  57. needs_remove = True
  58. logging.debug('generic mp4 format detected. '
  59. 'Running qtfaststart...')
  60. try:
  61. processor.process(temp_output, output)
  62. except FastStartException:
  63. logging.exception('qtfaststart: exception occurred')
  64. err = EnvironmentError('qtfaststart exception')
  65. else:
  66. try:
  67. shutil.move(temp_output, output)
  68. except EnvironmentError as e:
  69. needs_remove = True
  70. err = e
  71. # If it didn't work for some reason try to clean up the stale stuff.
  72. # And if that doesn't work ... just log, and re-raise the original
  73. # error.
  74. if needs_remove:
  75. try:
  76. os.remove(temp_output)
  77. except EnvironmentError as e:
  78. logging.error('finalize(): cannot remove stale file %r',
  79. temp_output)
  80. if err:
  81. logging.error('finalize(): removal was in response to '
  82. 'error: %s', str(err))
  83. raise err
  84. def get_target_size(self, video):
  85. """Get the size that we will convert to for a given video.
  86. :returns: (width, height) tuple
  87. """
  88. return utils.rescale_video((video.width, video.height),
  89. (self.width, self.height),
  90. dont_upsize=self.dont_upsize)
  91. def process_status_line(self, line):
  92. raise NotImplementedError
  93. class FFmpegConverterInfo(ConverterInfo):
  94. """Base class for all ffmpeg-based conversions.
  95. Subclasses must override the parameters attribute and supply it with the
  96. ffmpeg command line for the conversion. parameters can either be a list
  97. of arguments, or a string in which case split() will be called to create
  98. the list.
  99. """
  100. DURATION_RE = re.compile(r'\W*Duration: (\d\d):(\d\d):(\d\d)\.(\d\d)'
  101. '(, start:.*)?(, bitrate:.*)?')
  102. PROGRESS_RE = re.compile(r'(?:frame=.* fps=.* q=.* )?size=.* time=(.*) '
  103. 'bitrate=(.*)')
  104. LAST_PROGRESS_RE = re.compile(r'frame=.* fps=.* q=.* Lsize=.* time=(.*) '
  105. 'bitrate=(.*)')
  106. extension = None
  107. parameters = None
  108. def get_executable(self):
  109. return settings.get_ffmpeg_executable_path()
  110. def get_arguments(self, video, output):
  111. args = ['-i', utils.convert_path_for_subprocess(video.filename),
  112. '-strict', 'experimental']
  113. args.extend(settings.customize_ffmpeg_parameters(
  114. self.get_parameters(video)))
  115. if not (self.audio_only or video.audio_only):
  116. width, height = self.get_target_size(video)
  117. args.append("-s")
  118. args.append('%ix%i' % (width, height))
  119. args.extend(self.get_extra_arguments(video, output))
  120. args.append(self.convert_output_path(output))
  121. return args
  122. def convert_output_path(self, output_path):
  123. """Convert our output path so that it can be passed to ffmpeg."""
  124. # this is a bit tricky, because output_path doesn't exist on windows
  125. # yet, so we can't just call convert_path_for_subprocess(). Instead,
  126. # call convert_path_for_subprocess() on the output directory, and
  127. # assume that the filename only contains safe characters
  128. output_dir = os.path.dirname(output_path)
  129. output_filename = os.path.basename(output_path)
  130. return os.path.join(utils.convert_path_for_subprocess(output_dir),
  131. output_filename)
  132. def get_extra_arguments(self, video, output):
  133. """Subclasses can override this to add argumenst to the ffmpeg command
  134. line.
  135. """
  136. return []
  137. def get_parameters(self, video):
  138. if self.parameters is None:
  139. raise ValueError("%s: parameters is None" % self)
  140. elif isinstance(self.parameters, basestring):
  141. return self.parameters.split()
  142. else:
  143. return list(self.parameters)
  144. @staticmethod
  145. def _check_for_errors(line):
  146. if line.startswith('Unknown'):
  147. return line
  148. if line.startswith("Error"):
  149. if not line.startswith("Error while decoding stream"):
  150. return line
  151. @classmethod
  152. def process_status_line(klass, video, line):
  153. error = klass._check_for_errors(line)
  154. if error:
  155. return {'finished': True, 'error': error}
  156. match = klass.DURATION_RE.match(line)
  157. if match is not None:
  158. hours, minutes, seconds, centi = [
  159. int(m) for m in match.groups()[:4]]
  160. return {'duration': hms_to_seconds(hours, minutes,
  161. seconds + 0.01 * centi)}
  162. match = klass.PROGRESS_RE.match(line)
  163. if match is not None:
  164. t = match.group(1)
  165. if ':' in t:
  166. hours, minutes, seconds = [float(m) for m in t.split(':')[:3]]
  167. return {'progress': hms_to_seconds(hours, minutes, seconds)}
  168. else:
  169. return {'progress': float(t)}
  170. match = klass.LAST_PROGRESS_RE.match(line)
  171. if match is not None:
  172. return {'finished': True}
  173. class FFmpegConverterInfo1080p(FFmpegConverterInfo):
  174. def __init__(self, name):
  175. FFmpegConverterInfo.__init__(self, name, 1920, 1080)
  176. class FFmpegConverterInfo720p(FFmpegConverterInfo):
  177. def __init__(self, name):
  178. FFmpegConverterInfo.__init__(self, name, 1080, 720)
  179. class FFmpegConverterInfo480p(FFmpegConverterInfo):
  180. def __init__(self, name):
  181. FFmpegConverterInfo.__init__(self, name, 720, 480)
  182. class ConverterManager(object):
  183. def __init__(self):
  184. self.converters = {}
  185. # converter -> brand reverse map. XXX: this code, really, really sucks
  186. # and not very scalable.
  187. self.brand_rmap = {}
  188. self.brand_map = {}
  189. def add_converter(self, converter):
  190. self.converters[converter.identifier] = converter
  191. def startup(self):
  192. self.load_simple_converters()
  193. self.load_converters(resources.converter_scripts())
  194. def brand_to_converters(self, brand):
  195. try:
  196. return self.brand_map[brand]
  197. except KeyError:
  198. return None
  199. def converter_to_brand(self, converter):
  200. try:
  201. return self.brand_rmap[converter]
  202. except KeyError:
  203. return None
  204. def load_simple_converters(self):
  205. from lvc import basicconverters
  206. for converter in basicconverters.converters:
  207. if isinstance(converter, tuple):
  208. brand, realconverters = converter
  209. for realconverter in realconverters:
  210. self.brand_rmap[realconverter] = brand
  211. self.brand_map.setdefault(brand, []).append(realconverter)
  212. self.add_converter(realconverter)
  213. else:
  214. self.brand_rmap[converter] = None
  215. self.brand_map.setdefault(None, []).append(converter)
  216. self.add_converter(converter)
  217. def load_converters(self, converters):
  218. for converter_file in converters:
  219. global_dict = {}
  220. execfile(converter_file, global_dict)
  221. if 'converters' in global_dict:
  222. for converter in global_dict['converters']:
  223. if isinstance(converter, tuple):
  224. brand, realconverters = converter
  225. for realconverter in realconverters:
  226. self.brand_rmap[realconverter] = brand
  227. self.brand_map.setdefault(brand,
  228. []).append(realconverter)
  229. self.add_converter(realconverter)
  230. else:
  231. self.brand_rmap[converter] = None
  232. self.brand_map.setdefault(None, []).append(converter)
  233. self.add_converter(converter)
  234. logger.info('load_converters: loaded %i from %r',
  235. len(global_dict['converters']),
  236. converter_file)
  237. def list_converters(self):
  238. return self.converters.values()
  239. def get_by_id(self, id_):
  240. return self.converters[id_]