aboutsummaryrefslogtreecommitdiffstats
path: root/lvc/converter.py
blob: deefbfe5d2230a843a43740f7d062a2ea31d0da9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
#!/usr/bin/python
# -*- coding: utf-8 -*-
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 as 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 as 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_]