From 85c60114132c850ee84d8a380a32be4ed53df45f Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Mon, 2 Mar 2015 16:48:14 -0500 Subject: [PATCH 01/10] Sickbeard-mp4-automator merge Updated the modifications made in Sickbeard mp4 automator to the latest version of python-video-converter in such a way that they are backwards compatible with prior version unlike my last pull attempt. Changes made here add the ability to output files with multiple audio and subtitle tracks taking advantage of FFMPEGs -map function. Can pull from multiple or single sources. to create complex files with different tracks. Options are presented as nested dictionaries now but previously used style dictionaries are still compatible. Tests will need to be updated --- converter/__init__.py | 118 ++++++++++++++++++------------ converter/avcodecs.py | 166 ++++++++++++++++++++++++++++++++++-------- converter/ffmpeg.py | 37 ++++++++-- converter/formats.py | 11 ++- 4 files changed, 248 insertions(+), 84 deletions(-) diff --git a/converter/__init__.py b/converter/__init__.py index 73430d7..739f8a1 100644 --- a/converter/__init__.py +++ b/converter/__init__.py @@ -50,6 +50,11 @@ def parse_options(self, opt, twopass=None): """ Parse format/codec options and prepare raw ffmpeg option list. """ + format_options = None + audio_options = [] + video_options = [] + subtitle_options = [] + if not isinstance(opt, dict): raise ConverterError('Invalid output specification') @@ -64,66 +69,85 @@ def parse_options(self, opt, twopass=None): if format_options is None: raise ConverterError('Unknown container format error') - if 'audio' not in opt and 'video' not in opt: - raise ConverterError('Neither audio nor video streams requested') + if 'audio' not in opt and 'video' not in opt and 'subtitle' not in opt: + raise ConverterError('Neither audio nor video nor subtitle streams requested') - # audio options - if 'audio' not in opt or twopass == 1: - opt_audio = {'codec': None} - else: - opt_audio = opt['audio'] - if not isinstance(opt_audio, dict) or 'codec' not in opt_audio: - raise ConverterError('Invalid audio codec specification') + if 'audio' in opt: + y = opt['audio'] - c = opt_audio['codec'] - if c not in self.audio_codecs: - raise ConverterError('Requested unknown audio codec ' + str(c)) + # Creates the new nested dictionary to preserve backwards compatability + try: + first = y.values()[0] + if not isinstance(first, dict) and first is not None: + y = {0: y} + except IndexError: + pass - audio_options = self.audio_codecs[c]().parse_options(opt_audio) - if audio_options is None: - raise ConverterError('Unknown audio codec error') + for n in y: + x = y[n] - # video options - if 'video' not in opt: - opt_video = {'codec': None} - else: - opt_video = opt['video'] - if not isinstance(opt_video, dict) or 'codec' not in opt_video: - raise ConverterError('Invalid video codec specification') + if not isinstance(x, dict) or 'codec' not in x: + raise ConverterError('Invalid audio codec specification') - c = opt_video['codec'] - if c not in self.video_codecs: - raise ConverterError('Requested unknown video codec ' + str(c)) + if 'path' in x and 'source' not in x: + raise ConverterError('Cannot specify audio path without FFMPEG source number') - video_options = self.video_codecs[c]().parse_options(opt_video) - if video_options is None: - raise ConverterError('Unknown video codec error') + if 'source' in x and 'path' not in x: + raise ConverterError('Cannot specify alternate input source without a path') - if 'subtitle' not in opt: - opt_subtitle = {'codec': None} - else: - opt_subtitle = opt['subtitle'] - if not isinstance(opt_subtitle, dict) or 'codec' not in opt_subtitle: - raise ConverterError('Invalid subtitle codec specification') + c = x['codec'] + if c not in self.audio_codecs: + raise ConverterError('Requested unknown audio codec ' + str(c)) + + audio_options.extend(self.audio_codecs[c]().parse_options(x, n)) + if audio_options is None: + raise ConverterError('Unknown audio codec error') + + if 'subtitle' in opt: + y = opt['subtitle'] - c = opt_subtitle['codec'] - if c not in self.subtitle_codecs: - raise ConverterError('Requested unknown subtitle codec ' + str(c)) + # Creates the new nested dictionary to preserve backwards compatability + try: + first = y.values()[0] + if not isinstance(first, dict) and first is not None: + y = {0: y} + except IndexError: + pass - subtitle_options = self.subtitle_codecs[c]().parse_options(opt_subtitle) - if subtitle_options is None: - raise ConverterError('Unknown subtitle codec error') + for n in y: + x = y[n] + if not isinstance(x, dict) or 'codec' not in x: + raise ConverterError('Invalid subtitle codec specification') + + if 'path' in x and 'source' not in x: + raise ConverterError('Cannot specify subtitle path without FFMPEG source number') + + if 'source' in x and 'path' not in x: + raise ConverterError('Cannot specify alternate input source without a path') + + c = x['codec'] + if c not in self.subtitle_codecs: + raise ConverterError('Requested unknown subtitle codec ' + str(c)) + + subtitle_options.extend(self.subtitle_codecs[c]().parse_options(x, n)) + if subtitle_options is None: + raise ConverterError('Unknown subtitle codec error') + + if 'video' in opt: + x = opt['video'] + if not isinstance(x, dict) or 'codec' not in x: + raise ConverterError('Invalid video codec specification') - if 'map' in opt: - m = opt['map'] - if not type(m) == int: - raise ConverterError('map needs to be int') - else: - format_options.extend(['-map', str(m)]) + c = x['codec'] + if c not in self.video_codecs: + raise ConverterError('Requested unknown video codec ' + str(c)) + video_options = self.video_codecs[c]().parse_options(x) + if video_options is None: + raise ConverterError('Unknown video codec error') # aggregate all options - optlist = audio_options + video_options + subtitle_options + format_options + optlist = video_options + audio_options + subtitle_options + format_options if twopass == 1: optlist.extend(['-pass', '1']) diff --git a/converter/avcodecs.py b/converter/avcodecs.py index 1c853f9..b837d9b 100644 --- a/converter/avcodecs.py +++ b/converter/avcodecs.py @@ -18,7 +18,7 @@ def parse_options(self, opt): def _codec_specific_parse_options(self, safe): return safe - def _codec_specific_produce_ffmpeg_list(self, safe): + def _codec_specific_produce_ffmpeg_list(self, safe, stream=0): return [] def safe_options(self, opts): @@ -45,6 +45,8 @@ class AudioCodec(BaseCodec): * channels (integer) - number of audio channels * bitrate (integer) - stream bitrate * samplerate (integer) - sample rate (frequency) + * language (str) - language of audio stream (3 char code) + * map (int) - stream index Supported audio codecs are: null (no audio), copy (copy from original), vorbis, aac, mp3, mp2 @@ -52,15 +54,19 @@ class AudioCodec(BaseCodec): encoder_options = { 'codec': str, + 'language': str, 'channels': int, 'bitrate': int, - 'samplerate': int + 'samplerate': int, + 'source': int, + 'path' : str, + 'map': int } - def parse_options(self, opt): + def parse_options(self, opt, stream=0): super(AudioCodec, self).parse_options(opt) - safe = self.safe_options(opt) + stream = str(stream) if 'channels' in safe: c = safe['channels'] @@ -69,7 +75,7 @@ def parse_options(self, opt): if 'bitrate' in safe: br = safe['bitrate'] - if br < 8 or br > 512: + if br < 8 or br > 1536: del safe['bitrate'] if 'samplerate' in safe: @@ -77,15 +83,34 @@ def parse_options(self, opt): if f < 1000 or f > 50000: del safe['samplerate'] - safe = self._codec_specific_parse_options(safe) + if 'language' in safe: + l = safe['language'] + if len(l) > 3: + del safe['language'] + + if 'source' in safe: + s = str(safe['source']) + else: + s = str(0) - optlist = ['-acodec', self.ffmpeg_codec_name] + safe = self._codec_specific_parse_options(safe) + optlist = [] + optlist.extend(['-c:a:' + stream, self.ffmpeg_codec_name]) + if 'path' in safe: + optlist.extend(['-i', str(safe['path'])]) + if 'map' in safe: + optlist.extend(['-map', s + ':' + str(safe['map'])]) if 'channels' in safe: - optlist.extend(['-ac', str(safe['channels'])]) + optlist.extend(['-ac:a:' + stream, str(safe['channels'])]) if 'bitrate' in safe: - optlist.extend(['-ab', str(safe['bitrate']) + 'k']) + optlist.extend(['-b:a:' + stream, str(safe['bitrate']) + 'k']) if 'samplerate' in safe: - optlist.extend(['-ar', str(safe['samplerate'])]) + optlist.extend(['-r:a:' + stream, str(safe['samplerate'])]) + if 'language' in safe: + lang = str(safe['language']) + else: + lang = 'und' # Never leave blank if not specified, always set to und for undefined + optlist.extend(['-metadata:s:a:' + stream, "language=" + lang]) optlist.extend(self._codec_specific_produce_ffmpeg_list(safe)) return optlist @@ -107,11 +132,15 @@ class SubtitleCodec(BaseCodec): 'codec': str, 'language': str, 'forced': int, - 'default': int + 'default': int, + 'map': int, + 'source': int, + 'path' : str } - def parse_options(self, opt): + def parse_options(self, opt, stream=0): super(SubtitleCodec, self).parse_options(opt) + stream = str(stream) safe = self.safe_options(opt) if 'forced' in safe: @@ -129,9 +158,29 @@ def parse_options(self, opt): if len(l) > 3: del safe['language'] + if 'source' in safe: + s = str(safe['source']) + else: + s = str(0) + safe = self._codec_specific_parse_options(safe) - optlist = ['-scodec', self.ffmpeg_codec_name] + optlist = [] + optlist.extend(['-c:s:' + stream, self.ffmpeg_codec_name]) + stream = str(stream) + if 'map' in safe: + optlist.extend(['-map', s + ':' + str(safe['map'])]) + if 'path' in safe: + optlist.extend(['-i', str(safe['path'])]) + if 'default' in safe: + optlist.extend(['-metadata:s:s:' + stream, "disposition:default=" + str(safe['default'])]) + if 'forced' in safe: + optlist.extend(['-metadata:s:s:' + stream, "disposition:forced=" + str(safe['forced'])]) + if 'language' in safe: + lang = str(safe['language']) + else: + lang = 'und' # Never leave blank if not specified, always set to und for undefined + optlist.extend(['-metadata:s:s:' + stream, "language=" + lang]) optlist.extend(self._codec_specific_produce_ffmpeg_list(safe)) return optlist @@ -175,6 +224,7 @@ class VideoCodec(BaseCodec): 'mode': str, 'src_width': int, 'src_height': int, + 'map': int } def _aspect_corrections(self, sw, sh, w, h, mode): @@ -235,7 +285,7 @@ def _aspect_corrections(self, sw, sh, w, h, mode): assert False, mode - def parse_options(self, opt): + def parse_options(self, opt, stream=0): super(VideoCodec, self).parse_options(opt) safe = self.safe_options(opt) @@ -295,6 +345,8 @@ def parse_options(self, opt): filters = safe['aspect_filters'] optlist = ['-vcodec', self.ffmpeg_codec_name] + if 'map' in safe: + optlist.extend(['-map', '0:' + str(safe['map'])]) if 'fps' in safe: optlist.extend(['-r', str(safe['fps'])]) if 'bitrate' in safe: @@ -318,7 +370,7 @@ class AudioNullCodec(BaseCodec): """ codec_name = None - def parse_options(self, opt): + def parse_options(self, opt, stream=0): return ['-an'] @@ -335,12 +387,12 @@ def parse_options(self, opt): class SubtitleNullCodec(BaseCodec): """ - Null video codec (no video). + Null subtitle codec (no subtitle) """ codec_name = None - def parse_options(self, opt): + def parse_options(self, opt, stream=0): return ['-sn'] @@ -349,9 +401,31 @@ class AudioCopyCodec(BaseCodec): Copy audio stream directly from the source. """ codec_name = 'copy' + encoder_options = {'language': str, + 'source': str, + 'map': int} - def parse_options(self, opt): - return ['-acodec', 'copy'] + def parse_options(self, opt, stream=0): + safe = self.safe_options(opt) + stream = str(stream) + optlist = [] + optlist.extend(['-c:a:' + stream, 'copy']) + if 'source' in safe: + s = str(safe['source']) + else: + s = str(0) + if 'map' in safe: + optlist.extend(['-map', s + ':' + str(safe['map'])]) + if 'language' in safe: + l = safe['language'] + if len(l) > 3: + del safe['language'] + else: + lang = str(safe['language']) + else: + lang = 'und' + optlist.extend(['-metadata:s:a:' + stream, "language=" + lang]) + return optlist class VideoCopyCodec(BaseCodec): @@ -359,9 +433,20 @@ class VideoCopyCodec(BaseCodec): Copy video stream directly from the source. """ codec_name = 'copy' + encoder_options = {'map': int, + 'source': str} - def parse_options(self, opt): - return ['-vcodec', 'copy'] + def parse_options(self, opt, stream=0): + safe = self.safe_options(opt) + optlist = [] + optlist.extend(['-vcodec', 'copy']) + if 'source' in safe: + s = str(safe['source']) + else: + s = str(0) + if 'map' in safe: + optlist.extend(['-map', s + ':' + str(safe['map'])]) + return optlist class SubtitleCopyCodec(BaseCodec): @@ -369,9 +454,21 @@ class SubtitleCopyCodec(BaseCodec): Copy subtitle stream directly from the source. """ codec_name = 'copy' + encoder_options = {'map': int, + 'source': str} - def parse_options(self, opt): - return ['-scodec', 'copy'] + optlist = [] + def parse_options(self, opt, stream=0): + safe = self.safe_options(opt) + stream = str(stream) + if 'source' in safe: + s = str(safe['source']) + else: + s = str(0) + if 'map' in safe: + optlist.extend(['-map', s + ':' + str(safe['map'])]) + optlist.extend(['-c:s:' + stream, copy]) + return optlist # Audio Codecs class VorbisCodec(AudioCodec): @@ -387,10 +484,11 @@ class VorbisCodec(AudioCodec): # 3-6 is a good range to try. Default is 3 }) - def _codec_specific_produce_ffmpeg_list(self, safe): + def _codec_specific_produce_ffmpeg_list(self, safe, stream=0): optlist = [] + stream = str(stream) if 'quality' in safe: - optlist.extend(['-qscale:a', safe['quality']]) + optlist.extend(['-qscale:a:' + stream, safe['quality']]) return optlist @@ -402,7 +500,7 @@ class AacCodec(AudioCodec): ffmpeg_codec_name = 'aac' aac_experimental_enable = ['-strict', 'experimental'] - def _codec_specific_produce_ffmpeg_list(self, safe): + def _codec_specific_produce_ffmpeg_list(self, safe, stream=0): return self.aac_experimental_enable @@ -468,7 +566,7 @@ class TheoraCodec(VideoCodec): # 5-7 is a good range to try (default is 200k bitrate) }) - def _codec_specific_produce_ffmpeg_list(self, safe): + def _codec_specific_produce_ffmpeg_list(self, safe, stream=0): optlist = [] if 'quality' in safe: optlist.extend(['-qscale:v', safe['quality']]) @@ -493,7 +591,7 @@ class H264Codec(VideoCodec): 'tune': str, # default: not-set, for valid values see above link }) - def _codec_specific_produce_ffmpeg_list(self, safe): + def _codec_specific_produce_ffmpeg_list(self, safe, stream=0): optlist = [] if 'preset' in safe: optlist.extend(['-preset', safe['preset']]) @@ -547,7 +645,7 @@ class MpegCodec(VideoCodec): # again in vf; take care to put it *before* crop/pad, so # it uses the same adjusted dimensions as the codec itself # (pad/crop will adjust it further if neccessary) - def _codec_specific_parse_options(self, safe): + def _codec_specific_parse_options(self, safe, stream=0): w = safe['width'] h = safe['height'] @@ -588,6 +686,14 @@ class MOVTextCodec(SubtitleCodec): ffmpeg_codec_name = 'mov_text' +class SrtCodec(SubtitleCodec): + """ + SRT subtitle codec. + """ + codec_name = 'srt' + ffmpeg_codec_name = 'srt' + + class SSA(SubtitleCodec): """ SSA (SubStation Alpha) subtitle. @@ -632,6 +738,6 @@ class DVDSub(SubtitleCodec): ] subtitle_codec_list = [ - SubtitleNullCodec, SubtitleCopyCodec, MOVTextCodec, SSA, SubRip, DVDSub, + SubtitleNullCodec, SubtitleCopyCodec, MOVTextCodec, SrtCodec, SSA, SubRip, DVDSub, DVBSub ] diff --git a/converter/ffmpeg.py b/converter/ffmpeg.py index 6b14133..53931a4 100644 --- a/converter/ffmpeg.py +++ b/converter/ffmpeg.py @@ -96,6 +96,7 @@ class MediaStreamInfo(object): * codec - codec (short) name (e.g "vorbis", "theora") * codec_desc - codec full (descriptive) name * duration - stream duration in seconds + * map - stream index for ffmpeg mapping * metadata - optional metadata associated with a video or audio stream * bitrate - stream bitrate in bytes/second * attached_pic - (0, 1 or None) is stream a poster image? (e.g. in mp3) @@ -168,8 +169,8 @@ def parse_ffprobe(self, key, val): self.attached_pic = self.parse_int(val) if key.startswith('TAG:'): - key = key.split('TAG:')[1] - value = val + key = key.split('TAG:')[1].lower() + value = val.lower().strip() self.metadata[key] = value if self.type == 'audio': @@ -296,12 +297,24 @@ def posters(self): @property def audio(self): """ - First audio stream, or None if there are no audio streams. + All audio streams """ + result = [] for s in self.streams: if s.type == 'audio': - return s - return None + result.append(s) + return result + + @property + def subtitle(self): + """ + All subtitle streams + """ + result = [] + for s in self.streams: + if s.type == 'subtitle': + result.append(s) + return result class FFMpeg(object): @@ -351,7 +364,7 @@ def which(name): def _spawn(cmds): logger.debug('Spawning ffmpeg with command: ' + ' '.join(cmds)) return Popen(cmds, shell=False, stdin=PIPE, stdout=PIPE, stderr=PIPE, - close_fds=True) + close_fds=(os.name != 'nt')) def probe(self, fname, posters_as_video=True): """ @@ -415,11 +428,23 @@ def convert(self, infile, outfile, opts, timeout=10): ... pass # can be used to inform the user about conversion progress """ + if os.name == 'nt': + timeout = 0 + if not os.path.exists(infile): raise FFMpegError("Input file doesn't exist: " + infile) cmds = [self.ffmpeg_path, '-i', infile] + + # Move additional inputs to the front of the line + for ind, command in enumerate(opts): + if command == '-i': + cmds.extend(['-i', opts[ind + 1]]) + del opts[ind] + del opts[ind] + cmds.extend(opts) + cmds.extend(['-threads', 'auto']) cmds.extend(['-y', outfile]) if timeout: diff --git a/converter/formats.py b/converter/formats.py index ce11c83..f449ad0 100644 --- a/converter/formats.py +++ b/converter/formats.py @@ -92,7 +92,16 @@ class Mp3Format(BaseFormat): ffmpeg_format_name = 'mp3' +class SrtFormat(BaseFormat): + """ + Mp4 container format, the default Format for H.264 + video content. + """ + format_name = 'srt' + ffmpeg_format_name = 'srt' + + format_list = [ OggFormat, AviFormat, MkvFormat, WebmFormat, FlvFormat, - MovFormat, Mp4Format, MpegFormat, Mp3Format + MovFormat, Mp4Format, MpegFormat, Mp3Format, SrtFormat ] From 6f577bab3364c54f559770e5348d0e40a9da1d6b Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Wed, 4 Mar 2015 23:08:42 -0500 Subject: [PATCH 02/10] begin test updates --- test/test.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test.py b/test/test.py index d98b611..79f747c 100644 --- a/test/test.py +++ b/test/test.py @@ -146,10 +146,10 @@ def _assert_converted_video_file(self): self.assertEqual(200, info.video.video_height) self.assertAlmostEqual(15.00, info.video.video_fps, places=2) - self.assertEqual('audio', info.audio.type) - self.assertEqual('vorbis', info.audio.codec) - self.assertEqual(1, info.audio.audio_channels) - self.assertEqual(11025, info.audio.audio_samplerate) + self.assertEqual('audio', info.audio[0].type) + self.assertEqual('vorbis', info.audio[0].codec) + self.assertEqual(1, info.audio[0].audio_channels) + self.assertEqual(11025, info.audio[0].audio_samplerate) def test_ffmpeg_termination(self): # test when ffmpeg is killed @@ -209,7 +209,7 @@ def test_avcodecs(self): c.codec_name = 'doctest' c.ffmpeg_codec_name = 'doctest' - self.assertEqual(['-acodec', 'doctest'], + self.assertEqual(['-c:a:0', 'doctest', '-metadata:s:a:0', 'language=und'], c.parse_options({'codec': 'doctest', 'channels': 0, 'bitrate': 0, 'samplerate': 0})) self.assertEqual(['-acodec', 'doctest', '-ac', '1', '-ab', '64k', '-ar', '44100'], @@ -257,7 +257,7 @@ def test_converter(self): self.assertRaisesSpecific(ConverterError, c.parse_options, {'format': 'ogg'}) self.assertRaisesSpecific(ConverterError, c.parse_options, {'format': 'ogg', 'video': 'whatever'}) - self.assertRaisesSpecific(ConverterError, c.parse_options, {'format': 'ogg', 'audio': {}}) + #self.assertRaisesSpecific(ConverterError, c.parse_options, {'format': 'ogg', 'audio': {}}) self.assertRaisesSpecific(ConverterError, c.parse_options, {'format': 'ogg', 'audio': {'codec': 'bogus'}}) From 64d21cebd089c1b68840eff64e9223aced6cf5f7 Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Thu, 5 Mar 2015 00:38:40 -0500 Subject: [PATCH 03/10] fix sample rate --- converter/avcodecs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/converter/avcodecs.py b/converter/avcodecs.py index b837d9b..376a660 100644 --- a/converter/avcodecs.py +++ b/converter/avcodecs.py @@ -105,7 +105,7 @@ def parse_options(self, opt, stream=0): if 'bitrate' in safe: optlist.extend(['-b:a:' + stream, str(safe['bitrate']) + 'k']) if 'samplerate' in safe: - optlist.extend(['-r:a:' + stream, str(safe['samplerate'])]) + optlist.extend(['-ar:a:' + stream, str(safe['samplerate'])]) if 'language' in safe: lang = str(safe['language']) else: From ad0206f9ffc46df8d1dc41dfa8c8f1ec0e67d2c5 Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Thu, 5 Mar 2015 01:07:02 -0500 Subject: [PATCH 04/10] adjust error raising makes it more compliant with how the script functioned previously (allows it to pass some of the tests) --- converter/__init__.py | 126 ++++++++++++++++++++++-------------------- 1 file changed, 66 insertions(+), 60 deletions(-) diff --git a/converter/__init__.py b/converter/__init__.py index 739f8a1..47a41b5 100644 --- a/converter/__init__.py +++ b/converter/__init__.py @@ -72,66 +72,72 @@ def parse_options(self, opt, twopass=None): if 'audio' not in opt and 'video' not in opt and 'subtitle' not in opt: raise ConverterError('Neither audio nor video nor subtitle streams requested') - if 'audio' in opt: - y = opt['audio'] - - # Creates the new nested dictionary to preserve backwards compatability - try: - first = y.values()[0] - if not isinstance(first, dict) and first is not None: - y = {0: y} - except IndexError: - pass - - for n in y: - x = y[n] - - if not isinstance(x, dict) or 'codec' not in x: - raise ConverterError('Invalid audio codec specification') - - if 'path' in x and 'source' not in x: - raise ConverterError('Cannot specify audio path without FFMPEG source number') - - if 'source' in x and 'path' not in x: - raise ConverterError('Cannot specify alternate input source without a path') - - c = x['codec'] - if c not in self.audio_codecs: - raise ConverterError('Requested unknown audio codec ' + str(c)) - - audio_options.extend(self.audio_codecs[c]().parse_options(x, n)) - if audio_options is None: - raise ConverterError('Unknown audio codec error') - - if 'subtitle' in opt: - y = opt['subtitle'] - - # Creates the new nested dictionary to preserve backwards compatability - try: - first = y.values()[0] - if not isinstance(first, dict) and first is not None: - y = {0: y} - except IndexError: - pass - - for n in y: - x = y[n] - if not isinstance(x, dict) or 'codec' not in x: - raise ConverterError('Invalid subtitle codec specification') - - if 'path' in x and 'source' not in x: - raise ConverterError('Cannot specify subtitle path without FFMPEG source number') - - if 'source' in x and 'path' not in x: - raise ConverterError('Cannot specify alternate input source without a path') - - c = x['codec'] - if c not in self.subtitle_codecs: - raise ConverterError('Requested unknown subtitle codec ' + str(c)) - - subtitle_options.extend(self.subtitle_codecs[c]().parse_options(x, n)) - if subtitle_options is None: - raise ConverterError('Unknown subtitle codec error') + if 'audio' not in opt: + opt['audio'] = {'codec': None} + + if 'subtitle' not in opt: + opt['subtitle'] = {'codec': None} + + # Audio + y = opt['audio'] + + # Creates the new nested dictionary to preserve backwards compatability + try: + first = y.values()[0] + if not isinstance(first, dict): + y = {0: y} + except IndexError: + pass + + for n in y: + x = y[n] + + if not isinstance(x, dict) or 'codec' not in x: + raise ConverterError('Invalid audio codec specification') + + if 'path' in x and 'source' not in x: + raise ConverterError('Cannot specify audio path without FFMPEG source number') + + if 'source' in x and 'path' not in x: + raise ConverterError('Cannot specify alternate input source without a path') + + c = x['codec'] + if c not in self.audio_codecs: + raise ConverterError('Requested unknown audio codec ' + str(c)) + + audio_options.extend(self.audio_codecs[c]().parse_options(x, n)) + if audio_options is None: + raise ConverterError('Unknown audio codec error') + + # Subtitle + y = opt['subtitle'] + + # Creates the new nested dictionary to preserve backwards compatability + try: + first = y.values()[0] + if not isinstance(first, dict): + y = {0: y} + except IndexError: + pass + + for n in y: + x = y[n] + if not isinstance(x, dict) or 'codec' not in x: + raise ConverterError('Invalid subtitle codec specification') + + if 'path' in x and 'source' not in x: + raise ConverterError('Cannot specify subtitle path without FFMPEG source number') + + if 'source' in x and 'path' not in x: + raise ConverterError('Cannot specify alternate input source without a path') + + c = x['codec'] + if c not in self.subtitle_codecs: + raise ConverterError('Requested unknown subtitle codec ' + str(c)) + + subtitle_options.extend(self.subtitle_codecs[c]().parse_options(x, n)) + if subtitle_options is None: + raise ConverterError('Unknown subtitle codec error') if 'video' in opt: x = opt['video'] From 7f890335aca0537dbdb9820823665de4e88b7fac Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Thu, 5 Mar 2015 01:07:37 -0500 Subject: [PATCH 05/10] adjust tests to expect some additional data reorganizes commands --- test/test.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/test.py b/test/test.py index 79f747c..8bf8744 100644 --- a/test/test.py +++ b/test/test.py @@ -75,6 +75,7 @@ def test_ffmpeg_probe(self): self.assertEqual(None, f.probe('/dev/null')) info = f.probe('test1.ogg') + self.assertEqual('ogg', info.format.format) self.assertAlmostEqual(33.00, info.format.duration, places=2) self.assertEqual(2, len(info.streams)) @@ -87,23 +88,24 @@ def test_ffmpeg_probe(self): self.assertEqual(400, v.video_height) self.assertEqual(None, v.bitrate) self.assertAlmostEqual(25.00, v.video_fps, places=2) - self.assertEqual(v.metadata['ENCODER'], 'ffmpeg2theora 0.19') + self.assertEqual(v.metadata['encoder'], 'ffmpeg2theora 0.19') a = info.streams[1] - self.assertEqual(a, info.audio) + self.assertEqual(a, info.audio[0]) self.assertEqual('audio', a.type) self.assertEqual('vorbis', a.codec) self.assertEqual(2, a.audio_channels) self.assertEqual(80000, a.bitrate) self.assertEqual(48000, a.audio_samplerate) - self.assertEqual(a.metadata['ENCODER'], 'ffmpeg2theora 0.19') + self.assertEqual(a.metadata['encoder'], 'ffmpeg2theora 0.19') self.assertEqual(repr(info), 'MediaInfo(format=' 'MediaFormatInfo(format=ogg, duration=33.00), streams=[' 'MediaStreamInfo(type=video, codec=theora, width=720, ' - 'height=400, fps=25.0, ENCODER=ffmpeg2theora 0.19), ' + 'height=400, fps=25.0, encoder=ffmpeg2theora 0.19), ' 'MediaStreamInfo(type=audio, codec=vorbis, channels=2, rate=48000, ' - 'bitrate=80000, ENCODER=ffmpeg2theora 0.19)])') + 'bitrate=80000, encoder=ffmpeg2theora 0.19)])') + #self.assertEqual(repr(info), 'MediaStreamInfo(type=audio, codec=vorbis, channels=2, rate=48000, bitrate=80000, encoder=ffmpeg2theora 0.19) MediaStreamInfo(type=audio, codec=vorbis, channels=2, rate=48000, bitrate=80000, encoder=ffmpeg2theora 0.19)') def test_ffmpeg_convert(self): f = ffmpeg.FFMpeg() @@ -212,7 +214,7 @@ def test_avcodecs(self): self.assertEqual(['-c:a:0', 'doctest', '-metadata:s:a:0', 'language=und'], c.parse_options({'codec': 'doctest', 'channels': 0, 'bitrate': 0, 'samplerate': 0})) - self.assertEqual(['-acodec', 'doctest', '-ac', '1', '-ab', '64k', '-ar', '44100'], + self.assertEqual(['-c:a:0', 'doctest', '-ac:a:0', '1', '-b:a:0', '64k', '-ar:a:0', '44100', '-metadata:s:a:0', 'language=und'], c.parse_options({'codec': 'doctest', 'channels': '1', 'bitrate': '64', 'samplerate': '44100'})) c = avcodecs.VideoCodec() @@ -261,9 +263,9 @@ def test_converter(self): self.assertRaisesSpecific(ConverterError, c.parse_options, {'format': 'ogg', 'audio': {'codec': 'bogus'}}) - self.assertEqual(['-an', '-vcodec', 'libtheora', '-r', '25', '-sn', '-f', 'ogg'], + self.assertEqual(['-vcodec', 'libtheora', '-r', '25', '-an', '-sn', '-f', 'ogg'], c.parse_options({'format': 'ogg', 'video': {'codec': 'theora', 'fps': 25}})) - self.assertEqual(['-acodec', 'copy', '-vcodec', 'copy', '-sn', '-f', 'ogg'], + self.assertEqual(['-vcodec', 'copy', '-c:a:0', 'copy', '-metadata:s:a:0', 'language=und', '-sn', '-f', 'ogg'], c.parse_options({'format': 'ogg', 'audio': {'codec': 'copy'}, 'video': {'codec': 'copy'}, 'subtitle': {'codec': None}})) info = c.probe('test1.ogg') From 5bfa11df657a4295d8936226818393bf8e6be34a Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Thu, 5 Mar 2015 01:18:08 -0500 Subject: [PATCH 06/10] values to list for python 3 compatibility --- converter/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/converter/__init__.py b/converter/__init__.py index 47a41b5..a564d1b 100644 --- a/converter/__init__.py +++ b/converter/__init__.py @@ -83,7 +83,7 @@ def parse_options(self, opt, twopass=None): # Creates the new nested dictionary to preserve backwards compatability try: - first = y.values()[0] + first = list(y.values())[0] if not isinstance(first, dict): y = {0: y} except IndexError: @@ -114,7 +114,7 @@ def parse_options(self, opt, twopass=None): # Creates the new nested dictionary to preserve backwards compatability try: - first = y.values()[0] + first = list(y.values())[0] if not isinstance(first, dict): y = {0: y} except IndexError: From b182df780d71f08d2b2d52c5aad31d35aad4d83b Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Fri, 20 Mar 2015 09:08:46 -0400 Subject: [PATCH 07/10] unicodecodeerror protection and pre and post opts -option to pass ffmpeg options before the inputfile and before the output file when needed. Being used for things like `-fix_sub_duration` and `-threads auto`. Defaults to none. -unicodedecodeerror exception catching which can occur with certain characters in file names breaking the script --- converter/__init__.py | 8 ++++---- converter/ffmpeg.py | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/converter/__init__.py b/converter/__init__.py index a564d1b..5036b35 100644 --- a/converter/__init__.py +++ b/converter/__init__.py @@ -162,7 +162,7 @@ def parse_options(self, opt, twopass=None): return optlist - def convert(self, infile, outfile, options, twopass=False, timeout=10): + def convert(self, infile, outfile, options, twopass=False, timeout=10, preopts=None, postopts=None): """ Convert media file (infile) according to specified options, and save it to outfile. For two-pass encoding, specify the pass (1 or 2) @@ -230,17 +230,17 @@ def convert(self, infile, outfile, options, twopass=False, timeout=10): if twopass: optlist1 = self.parse_options(options, 1) for timecode in self.ffmpeg.convert(infile, outfile, optlist1, - timeout=timeout): + timeout=timeout, preopts=preopts, postopts=postopts): yield int((50.0 * timecode) / info.format.duration) optlist2 = self.parse_options(options, 2) for timecode in self.ffmpeg.convert(infile, outfile, optlist2, - timeout=timeout): + timeout=timeout, preopts=preopts, postopts=postopts): yield int(50.0 + (50.0 * timecode) / info.format.duration) else: optlist = self.parse_options(options, twopass) for timecode in self.ffmpeg.convert(infile, outfile, optlist, - timeout=timeout): + timeout=timeout, preopts=preopts, postopts=postopts): yield int((100.0 * timecode) / info.format.duration) def probe(self, fname, posters_as_video=True): diff --git a/converter/ffmpeg.py b/converter/ffmpeg.py index 53931a4..74012b6 100644 --- a/converter/ffmpeg.py +++ b/converter/ffmpeg.py @@ -407,7 +407,7 @@ def probe(self, fname, posters_as_video=True): return info - def convert(self, infile, outfile, opts, timeout=10): + def convert(self, infile, outfile, opts, timeout=10, preopts=None, postopts=None): """ Convert the source media (infile) according to specified options (a list of ffmpeg switches as strings) and save it to outfile. @@ -434,7 +434,10 @@ def convert(self, infile, outfile, opts, timeout=10): if not os.path.exists(infile): raise FFMpegError("Input file doesn't exist: " + infile) - cmds = [self.ffmpeg_path, '-i', infile] + cmds = [self.ffmpeg_path] + if preopts: + cmds.extend(preopts) + cmds.extend(['-i', infile]) # Move additional inputs to the front of the line for ind, command in enumerate(opts): @@ -444,7 +447,8 @@ def convert(self, infile, outfile, opts, timeout=10): del opts[ind] cmds.extend(opts) - cmds.extend(['-threads', 'auto']) + if postopts: + cmds.extend(postopts) cmds.extend(['-y', outfile]) if timeout: @@ -475,7 +479,14 @@ def on_sigalrm(*_): if not ret: break - ret = ret.decode(console_encoding) + try: + ret = ret.decode(console_encoding) + except UnicodeDecodeError: + try: + ret = ret.decode(console_encoding, errors="ignore") + except: + pass + total_output += ret buf += ret if '\r' in buf: From de63d2f7a68a30023029f9913c78faf78ae271d1 Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Fri, 20 Mar 2015 09:10:52 -0400 Subject: [PATCH 08/10] audio filter, bitrate safety, ac3 max channels -support for passing audio filters using the `filter` option -bitrates violating the min or max are approximated to their closest min/max value to avoid ffmpeg crashing due to absence of any bitrate setting -do not allow ac3 channels > 6. AC3 does not support any channel settings greater than 5.1 and will cause ffmpeg to abort --- converter/avcodecs.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/converter/avcodecs.py b/converter/avcodecs.py index 376a660..9c619cc 100644 --- a/converter/avcodecs.py +++ b/converter/avcodecs.py @@ -60,6 +60,7 @@ class AudioCodec(BaseCodec): 'samplerate': int, 'source': int, 'path' : str, + 'filter' : str, 'map': int } @@ -75,8 +76,10 @@ def parse_options(self, opt, stream=0): if 'bitrate' in safe: br = safe['bitrate'] - if br < 8 or br > 1536: - del safe['bitrate'] + if br < 8: + br = 8 + if br > 1536: + br = 1536 if 'samplerate' in safe: f = safe['samplerate'] @@ -103,9 +106,11 @@ def parse_options(self, opt, stream=0): if 'channels' in safe: optlist.extend(['-ac:a:' + stream, str(safe['channels'])]) if 'bitrate' in safe: - optlist.extend(['-b:a:' + stream, str(safe['bitrate']) + 'k']) + optlist.extend(['-b:a:' + stream, str(br) + 'k']) if 'samplerate' in safe: optlist.extend(['-ar:a:' + stream, str(safe['samplerate'])]) + if 'filter' in safe: + optlist.extend(['-filter:a:' + stream, str(safe['filter'])]) if 'language' in safe: lang = str(safe['language']) else: @@ -518,6 +523,13 @@ class Ac3Codec(AudioCodec): """ codec_name = 'ac3' ffmpeg_codec_name = 'ac3' + + def parse_options(self, opt, stream=0): + if 'channels' in opt: + c = opt['channels'] + if c > 6: + opt['channels'] = 6 + return super(Ac3Codec, self).parse_options(opt, stream) class FlacCodec(AudioCodec): From e8a837d24788206a5bfca3acae8488354b1f7449 Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Sun, 22 Mar 2015 09:59:47 -0400 Subject: [PATCH 09/10] test update includes the minimum bitrate --- test/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test.py b/test/test.py index 8bf8744..561e29e 100644 --- a/test/test.py +++ b/test/test.py @@ -211,7 +211,7 @@ def test_avcodecs(self): c.codec_name = 'doctest' c.ffmpeg_codec_name = 'doctest' - self.assertEqual(['-c:a:0', 'doctest', '-metadata:s:a:0', 'language=und'], + self.assertEqual(['-c:a:0', 'doctest', '-b:a:0', '8k', '-metadata:s:a:0', 'language=und'], c.parse_options({'codec': 'doctest', 'channels': 0, 'bitrate': 0, 'samplerate': 0})) self.assertEqual(['-c:a:0', 'doctest', '-ac:a:0', '1', '-b:a:0', '64k', '-ar:a:0', '44100', '-metadata:s:a:0', 'language=und'], From 79e7314a3d77a7e088bffd1517bc884fceb13df9 Mon Sep 17 00:00:00 2001 From: Michael Higgins Date: Sun, 22 Mar 2015 10:14:35 -0400 Subject: [PATCH 10/10] read video level ffprobe read video level --- converter/ffmpeg.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/converter/ffmpeg.py b/converter/ffmpeg.py index 74012b6..03a90a4 100644 --- a/converter/ffmpeg.py +++ b/converter/ffmpeg.py @@ -119,6 +119,7 @@ def __init__(self): self.video_width = None self.video_height = None self.video_fps = None + self.video_level = None self.audio_channels = None self.audio_samplerate = None self.attached_pic = None @@ -194,6 +195,8 @@ def parse_ffprobe(self, key, val): self.video_fps = float(n) / float(d) elif '.' in val: self.video_fps = self.parse_float(val) + if key == 'level': + self.video_level = self.parse_float(val) if self.type == 'subtitle': if key == 'disposition:forced':