diff options
Diffstat (limited to 'lvc/converter.py')
-rw-r--r-- | lvc/converter.py | 278 |
1 files changed, 278 insertions, 0 deletions
diff --git a/lvc/converter.py b/lvc/converter.py new file mode 100644 index 0000000..ed4e16c --- /dev/null +++ b/lvc/converter.py @@ -0,0 +1,278 @@ +import json +import logging +import os +import re +import shutil + +from lvc import resources, settings, utils +from lvc.utils import hms_to_seconds + +from lvc.qtfaststart import processor +from lvc.qtfaststart.exceptions import FastStartException + +logger = logging.getLogger(__name__) + +NON_WORD_CHARS = re.compile(r"[^a-zA-Z0-9]+") + +class ConverterInfo(object): + """Describes a particular output converter + + ConverterInfo is the base class for all converters. Subclasses must + implement get_executable() and get_arguments() + + :attribue name: user-friendly name for this converter + :attribute identifier: unique id for this converter + :attribute width: output width for this converter, or None to copy the + input width. This attribute is set to a default on construction, but can + be changed to reflect the user overriding the default. + :attribute height: output height for this converter. Works just like + width + :attribute dont_upsize: should we allow upsizing for conversions? + """ + media_type = None + bitrate = None + extension = None + audio_only = False + + def __init__(self, name, width=None, height=None, dont_upsize=True): + self.name = name + self.identifier = NON_WORD_CHARS.sub("", name).lower() + self.width = width + self.height = height + self.dont_upsize = dont_upsize + + def get_executable(self): + raise NotImplementedError + + def get_arguments(self, video, output): + raise NotImplementedError + + def get_output_filename(self, video): + basename = os.path.basename(video.filename) + name, ext = os.path.splitext(basename) + if ext and ext[0] == '.': + ext = ext[1:] + extension = self.extension if self.extension else ext + return '%s.%s.%s' % (name, self.identifier, extension) + + def get_output_size_guess(self, video): + if not self.bitrate or not video.duration: + return None + if video.duration: + return self.bitrate * video.duration / 8 + + def finalize(self, temp_output, output): + err = None + needs_remove = False + if self.media_type == 'format' and self.extension == 'mp4': + needs_remove = True + logging.debug('generic mp4 format detected. ' + 'Running qtfaststart...') + try: + processor.process(temp_output, output) + except FastStartException: + logging.exception('qtfaststart: exception occurred') + err = EnvironmentError('qtfaststart exception') + else: + try: + shutil.move(temp_output, output) + except EnvironmentError, e: + needs_remove = True + err = e + # If it didn't work for some reason try to clean up the stale stuff. + # And if that doesn't work ... just log, and re-raise the original + # error. + if needs_remove: + try: + os.remove(temp_output) + except EnvironmentError, e: + logging.error('finalize(): cannot remove stale file %r', + temp_output) + if err: + logging.error('finalize(): removal was in response to ' + 'error: %s', str(err)) + raise err + + def get_target_size(self, video): + """Get the size that we will convert to for a given video. + + :returns: (width, height) tuple + """ + return utils.rescale_video((video.width, video.height), + (self.width, self.height), + dont_upsize=self.dont_upsize) + + def process_status_line(self, line): + raise NotImplementedError + +class FFmpegConverterInfo(ConverterInfo): + """Base class for all ffmpeg-based conversions. + + Subclasses must override the parameters attribute and supply it with the + ffmpeg command line for the conversion. parameters can either be a list + of arguments, or a string in which case split() will be called to create + the list. + """ + DURATION_RE = re.compile(r'\W*Duration: (\d\d):(\d\d):(\d\d)\.(\d\d)' + '(, start:.*)?(, bitrate:.*)?') + PROGRESS_RE = re.compile(r'(?:frame=.* fps=.* q=.* )?size=.* time=(.*) ' + 'bitrate=(.*)') + LAST_PROGRESS_RE = re.compile(r'frame=.* fps=.* q=.* Lsize=.* time=(.*) ' + 'bitrate=(.*)') + + extension = None + parameters = None + + def get_executable(self): + return settings.get_ffmpeg_executable_path() + + def get_arguments(self, video, output): + args = ['-i', utils.convert_path_for_subprocess(video.filename), + '-strict', 'experimental'] + args.extend(settings.customize_ffmpeg_parameters( + self.get_parameters(video))) + if not (self.audio_only or video.audio_only): + width, height = self.get_target_size(video) + args.append("-s") + args.append('%ix%i' % (width, height)) + args.extend(self.get_extra_arguments(video, output)) + args.append(self.convert_output_path(output)) + return args + + def convert_output_path(self, output_path): + """Convert our output path so that it can be passed to ffmpeg.""" + # this is a bit tricky, because output_path doesn't exist on windows + # yet, so we can't just call convert_path_for_subprocess(). Instead, + # call convert_path_for_subprocess() on the output directory, and + # assume that the filename only contains safe characters + output_dir = os.path.dirname(output_path) + output_filename = os.path.basename(output_path) + return os.path.join(utils.convert_path_for_subprocess(output_dir), + output_filename) + + def get_extra_arguments(self, video, output): + """Subclasses can override this to add argumenst to the ffmpeg command + line. + """ + return [] + + def get_parameters(self, video): + if self.parameters is None: + raise ValueError("%s: parameters is None" % self) + elif isinstance(self.parameters, basestring): + return self.parameters.split() + else: + return list(self.parameters) + + @staticmethod + def _check_for_errors(line): + if line.startswith('Unknown'): + return line + if line.startswith("Error"): + if not line.startswith("Error while decoding stream"): + return line + + @classmethod + def process_status_line(klass, video, line): + error = klass._check_for_errors(line) + if error: + return {'finished': True, 'error': error} + + match = klass.DURATION_RE.match(line) + if match is not None: + hours, minutes, seconds, centi = [ + int(m) for m in match.groups()[:4]] + return {'duration': hms_to_seconds(hours, minutes, + seconds + 0.01 * centi)} + + match = klass.PROGRESS_RE.match(line) + if match is not None: + t = match.group(1) + if ':' in t: + hours, minutes, seconds = [float(m) for m in t.split(':')[:3]] + return {'progress': hms_to_seconds(hours, minutes, seconds)} + else: + return {'progress': float(t)} + + match = klass.LAST_PROGRESS_RE.match(line) + if match is not None: + return {'finished': True} + +class FFmpegConverterInfo1080p(FFmpegConverterInfo): + def __init__(self, name): + FFmpegConverterInfo.__init__(self, name, 1920, 1080) + +class FFmpegConverterInfo720p(FFmpegConverterInfo): + def __init__(self, name): + FFmpegConverterInfo.__init__(self, name, 1080, 720) + +class FFmpegConverterInfo480p(FFmpegConverterInfo): + def __init__(self, name): + FFmpegConverterInfo.__init__(self, name, 720, 480) + +class ConverterManager(object): + def __init__(self): + self.converters = {} + # converter -> brand reverse map. XXX: this code, really, really sucks + # and not very scalable. + self.brand_rmap = {} + self.brand_map = {} + + def add_converter(self, converter): + self.converters[converter.identifier] = converter + + def startup(self): + self.load_simple_converters() + self.load_converters(resources.converter_scripts()) + + def brand_to_converters(self, brand): + try: + return self.brand_map[brand] + except KeyError: + return None + + def converter_to_brand(self, converter): + try: + return self.brand_rmap[converter] + except KeyError: + return None + + def load_simple_converters(self): + from lvc import basicconverters + for converter in basicconverters.converters: + if isinstance(converter, tuple): + brand, realconverters = converter + for realconverter in realconverters: + self.brand_rmap[realconverter] = brand + self.brand_map.setdefault(brand, []).append(realconverter) + self.add_converter(realconverter) + else: + self.brand_rmap[converter] = None + self.brand_map.setdefault(None, []).append(converter) + self.add_converter(converter) + + def load_converters(self, converters): + for converter_file in converters: + global_dict = {} + execfile(converter_file, global_dict) + if 'converters' in global_dict: + for converter in global_dict['converters']: + if isinstance(converter, tuple): + brand, realconverters = converter + for realconverter in realconverters: + self.brand_rmap[realconverter] = brand + self.brand_map.setdefault(brand, []).append(realconverter) + self.add_converter(realconverter) + else: + self.brand_rmap[converter] = None + self.brand_map.setdefault(None, []).append(converter) + self.add_converter(converter) + logger.info('load_converters: loaded %i from %r', + len(global_dict['converters']), + converter_file) + + def list_converters(self): + return self.converters.values() + + def get_by_id(self, id_): + return self.converters[id_] |