src/pyams_media/ffbase.py
changeset 0 fd39db613f8b
child 7 b930c820b6cd
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_media/ffbase.py	Wed Sep 02 15:31:55 2015 +0200
@@ -0,0 +1,614 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+# import standard library
+import json
+import logging
+logger = logging.getLogger('PyAMS (media)')
+
+import mimetypes
+import re
+import tempfile
+
+from os.path import dirname, basename
+from os import sep, remove
+from subprocess import Popen, PIPE
+
+# import interfaces
+from pyams_file.interfaces import IFile
+
+# import packages
+from pyams_file.file import get_magic_content_type
+from pyams_media.ffexception import FFException
+
+
+__all__ = ['FFmpeg', 'FFVideoEffect', 'FFAudioEffect']
+
+
+INPUT_BLOCK_SIZE = 1024 ** 2
+
+
+class FFmpeg(object):
+    """
+    FFmpeg Wrapper
+    """
+
+    # thanks to pyxcoder http://code.google.com/p/pyxcoder for
+    # the main idea
+    re_mainline = re.compile("^\s*Input #(\d+?), (.*?), from \'(.*?)\':$")
+    re_infoline = re.compile("^\s*Duration: (.*?), start: 0\.000000, bitrate: (\d+?) kb\/s$")
+    re_videoline = re.compile("^\s*Stream #(\d+:\d+?)\(?([A-Za-z]*)\)?: Video: (.*?), (.*?), (.*?), (.*?)$")
+    re_audioline = re.compile("^\s*Stream #(\d+:\d+?)\(?([A-Za-z]*)\)?: Audio: (.*?), (\d+?) Hz, (.*?), (.*?), (\d+?) kb\/s$")
+
+    def __init__(self, cmd="ffmpeg"):
+        self.__ffmpeg__ = cmd
+
+    def __exec__(self, *args):
+        """Build and execute a command line"""
+        cmdline = [self.__ffmpeg__]
+        if self.__ffmpeg__ == 'ffmpeg':
+            cmdline.append('-y')
+        use_stdin = None
+        for arg in args:
+            if IFile.providedBy(arg):
+                if len(args) == 2:
+                    # FFmpeg can't get media info from an input pipe
+                    # We have to write media content to temporary file
+                    suffix = '.tmp'
+                    content_type = get_magic_content_type(arg.data)
+                    if content_type:
+                        suffix = mimetypes.guess_extension(content_type) or suffix
+                    output = tempfile.NamedTemporaryFile(prefix='media_', suffix=suffix)
+                    output.write(arg.data)
+                    output.file.flush()
+                    cmdline.append(output.name)
+                else:
+                    use_stdin = arg
+                    cmdline.append('-')
+            elif hasattr(arg, 'read'):  # StringIO or any file like object
+                if len(args) == 2:
+                    # FFmpeg can't get media info from an input pipe
+                    # We have to write media content to temporary file
+                    arg.reset()
+                    content_type = get_magic_content_type(arg.read(4096))
+                    suffix = mimetypes.guess_extension(content_type) if content_type else '.tmp'
+                    output = tempfile.NamedTemporaryFile(prefix='media_', suffix=suffix)
+                    try:
+                        arg.reset()
+                    except:
+                        pass
+                    data = arg.read(INPUT_BLOCK_SIZE)
+                    while data:
+                        output.write(data)
+                        data = arg.read(INPUT_BLOCK_SIZE)
+                    output.file.flush()
+                    cmdline.append(output.name)
+                else:
+                    use_stdin = arg
+                    cmdline.append('-')
+            else:
+                cmdline.append(arg)
+        logger.debug("Running FFmpeg command line: {0}".format(cmdline))
+        p = Popen(cmdline, stdin=PIPE, stdout=PIPE, stderr=PIPE)
+        if use_stdin is not None:
+            if IFile.providedBy(use_stdin):
+                return p.communicate(use_stdin.data)
+            else:
+                use_stdin.reset()
+                return p.communicate(use_stdin.read())
+        else:
+            return p.communicate()
+
+    def render(self, effectchain, output):
+        """Create a new file by chaining audio/video effects"""
+        inputs = []
+        cmds = [[]]
+        outputs = []
+        # we want to operate on more objects that use the same file
+        # source, So, we have to split the effect chain in various
+        # intermediate jobs, then rebuild all
+        for index, effect in enumerate(effectchain):
+            if index == 1 and not effect in inputs:
+                inputs.append(effect)
+                cmds[len(cmds)-1].append(effect)
+            else:
+                outputs.append("%s%s%s-%s" % (dirname(output), sep, len(cmds), basename(output)))
+                cmds.append([])
+                input = []
+        # prcessing intermediate outputs
+        for index, output in enumerate(outputs):
+            cmd = ["-y", ]
+            cmd.extend(inputs[index].cmdline())
+            cmd.append(output)
+            self.__exec__(*cmd)
+        # procesing final output
+        cmd = ["-y", ]
+        for index, output in enumerate(outputs):
+            doc = FFEffect(output)
+            if index == 0 and inputs[index].offset():
+                doc.offset(inputs[index].offset())
+            cmd.extend(doc.cmdline())
+        cmd.append(output)
+        self.__exec__(*cmd)
+        # removing intermediate outputs
+        for tmp in outputs:
+            remove(tmp)
+
+    def info(self, input):
+        """Retrieve file information parsing command output"""
+        metadata = []
+        if IFile.providedBy(input) or isinstance(input, str) or hasattr(input, 'read'):
+            input = [input, ]
+        for i in range(0, len(input) * 2, 2):
+            input.insert(i, "-i")
+        if self.__ffmpeg__ == 'ffprobe':
+            input.extend(['-show_streams', '-print_format', 'json'])
+            probe = self.__exec__(*input)[0]  # stdout
+            metadata.extend(json.loads(probe.decode()).get('streams', []))
+        else:
+            lines = self.__exec__(*input)[1]  # stderr
+            for line in lines.split(b'\n'):
+                if isinstance(line, bytes):
+                    try:
+                        line = line.decode()
+                    except UnicodeDecodeError:
+                        logger.debug("Unicode decode error: {0}".format(line))
+                        continue
+                if FFmpeg.re_mainline.match(line):
+                    clip, vtype, filename = FFmpeg.re_mainline.match(line).groups()
+                    metadata.append({"vtype": vtype, "filename": filename, "video": [], "audio": []})
+                elif FFmpeg.re_infoline.match(line):
+                    current = len(metadata) - 1
+                    metadata[current]["duration"], metadata[current]["bitrate"] = FFmpeg.re_infoline.match(line).groups()
+                elif FFmpeg.re_audioline.match(line):
+                    clip, lang, codec, freq, chan, freqbit, bitrate = FFmpeg.re_audioline.match(line).groups()
+                    audiostream = {"codec": codec, "lang": lang, "freq": freq, "chan": chan, "freqbit": freqbit, "bitrate": bitrate}
+                    metadata[len(metadata) - 1]["audio"].append(audiostream)
+                elif FFmpeg.re_videoline.match(line):
+                    clip, lang, codec, pix_fmt, size, framerate = FFmpeg.re_videoline.match(line).groups()
+                    size = size.split(" ")
+                    videostream = {"codec": codec, "lang": lang, "pix_fmt": pix_fmt, "size": size, "framerate": framerate}
+                    metadata[len(metadata) - 1]["video"].append(videostream)
+        return metadata
+
+
+class FFEffect:
+    """
+        effect for a specified input file
+        each "set" method has an unset_* method 
+        to clear the effect of the former (e.g.
+        crop() and unset_crop() ), and a general
+        unset() method 
+    """
+
+    def __init__(self, inputfile, **args):
+        self.__file__ = inputfile
+        for opt in args.keys():
+            if opt not in ["b", "vframes", "r", "s", "aspect", "croptop",
+                           "cropbottom", "cropleft", "cropright", "padtop",
+                           "padbottom", "padleft", "padright", "padcolor",
+                           "vn", "bt", "maxrate", "minrate", "bufsize",
+                           "vcodec", "sameq", "pass", "newvideo", "pix_fmt",
+                           "sws_flag", "g", "intra", "vdt", "qscale",
+                           "qmin", "qmax", "qdiff", "qblur", "qcomp", "lmin",
+                           "lmax", "mblmin", "mblmax", "rc_init_cplx",
+                           "b_qfactor", "i_qfactor", "b_qoffset",
+                           "i_qoffset", "rc_eq", "rc_override", "me_method",
+                           "dct_algo", "idct_algo", "er", "ec", "bf", "mbd",
+                           "4mv", "part", "bug", "strict", "aic", "umv",
+                           "deinterlace", "ilme", "psnr", "vhook", "top",
+                           "dc", "vtag", "vbsf", "aframes", "ar", "ab", "ac",
+                           "an", "acodec", "newaudio", "alang", "t",
+                           "itsoffset", "ss", "dframes"]:
+                raise FFException("Error parsing option: %s" % opt)
+        self.__effects__ = args
+        self.__default__ = self.__effects__.copy()
+
+    def cmdline(self):
+        """ return a list of arguments """
+        cmd = ["-i", self.__file__]
+        for opt, value in self.__effects__.items():
+            cmd.append("-%s" % opt)
+            if value is not True:
+                cmd.append("%s" % value)
+        return cmd
+
+    def get_output(self, format=None, target='-', get_stderr=False):
+        if (format is None) and hasattr(self, '__metadata__'):
+            format = self.__metadata__.get('vtype')
+        stdout, stderr = FFmpeg().__exec__(*self.cmdline() + ['-f', format, target])
+        return (stdout, stderr) if get_stderr else stdout
+
+    def restore(self):
+        """
+            restore initial settings
+        """
+        self.__effects__ = self.__default__.copy()
+
+    def unset(self):
+        """
+            clear settings
+        """
+        self.__effects__ = {}
+
+    def duration(self, t=None):
+        """ restrict transcode sequence to duration specified """
+        if t:
+            self.__effects__["t"] = float(t)
+        return self.__effects__.get("t")
+
+    def unset_duration(self):
+        del self.__effects__["duration"]
+
+    def seek(self, ss=None):
+        """ seek to time position in seconds """
+        if ss:
+            self.__effects__["ss"] = float(ss)
+        return self.__effects__.get("ss")
+
+    def unset_seek(self):
+        del self.__effects__["ss"]
+
+    def offset(self, itsoffset=None):
+        """ Set the input time offset in seconds """
+        if itsoffset:
+            self.__effects__["itsoffset"] = itsoffset
+        return self.__effects__.get("itsoffset")
+
+    def unset_offset(self):
+        del self.__effects__["itsoffset"]
+
+    def dframes(self, dframes=None):
+        """ number of data frames to record """
+        if dframes:
+            self.__effects__["dframes"] = dframes
+        return self.__effects__.get("dframes")
+
+    def unset_dframes(self):
+        del self.__effects__["dframes"]
+
+
+class FFVideoEffect(FFEffect):
+    """
+        video effect
+    """
+
+    def __init__(self, inputfile=None, **args):
+        FFEffect.__init__(self, inputfile, **args)
+
+    def bitrate(self, b=None):
+        """ set video bitrate """
+        if b:
+            self.__effects__["b"] = "%sk" % int(b)
+        return self.__effects__.get("b")
+
+    def unset_bitrate(self):
+        del self.__effects__["b"]
+
+    def vframes(self, vframes=None):
+        """ set number of video frames to record """
+        if vframes:
+            self.__effects__["vframes"] = int(vframes)
+        return self.__effects__.get("vframes")
+
+    def unset_vframes(self):
+        del self.__effects__["vframes"]
+
+    def rate(self, r=None):
+        """ set frame rate """
+        if r:
+            self.__effects__["r"] = int(r)
+        return self.__effects__.get("r")
+
+    def unset_rate(self):
+        del self.__effects__["r"]
+
+    def size(self, s=None):
+        """ set frame size """
+        if s in ["sqcif", "qcif", "cif", "4cif", "qqvga", "qvga", "vga", "svga",
+                 "xga", "uxga", "qxga", "sxga", "qsxga", "hsxga", "wvga", "wxga",
+                 "wsxga", "wuxga", "wqxga", "wqsxga", "wquxga", "whsxga",
+                 "whuxga", "cga", "ega", "hd480", "hd720", "hd1080"]:
+            self.__effects__["s"] = s
+        elif s:
+            wh = s.split("x")
+            if len(wh) == 2 and int(wh[0]) and int(wh[1]):
+                self.__effects__["s"] = s
+            else:
+                raise FFException("Error parsing option: size")
+        return self.__effects__.get("s")
+
+    def unset_size(self):
+        del  self.__effects__["s"]
+
+    def aspect(self, aspect=None):
+        """ set aspect ratio """
+        if aspect:
+            self.__effects__["aspect"] = aspect
+        return self.__effects__.get("aspect")
+
+    def unset_aspect(self):
+        del self.__effects__["aspect"]
+
+    def crop(self, top=0, bottom=0, left=0, right=0):
+        """ set the crop size """
+        if top % 2:
+            top = top - 1
+        if bottom % 2:
+            bottom = bottom - 1
+        if left % 2:
+            left = left - 1
+        if right % 2:
+            right = right - 1
+        if top:
+            self.__effects__["croptop"] = top
+        if bottom:
+            self.__effects__["cropbottom"] = bottom
+        if left:
+            self.__effects__["cropleft"] = left
+        if right:
+            self.__effects__["cropright"] = right
+        return self.__effects__.get("croptop"), self.__effects__.get("cropbottom"), self.__effects__.get("cropleft"), self.__effects__.get("cropright")
+
+    def unset_crop(self):
+        del self.__effects__["croptop"]
+        del self.__effects__["cropbottom"]
+        del self.__effects__["cropleft"]
+        del self.__effects__["cropright"]
+
+    def pad(self, top=0, bottom=0, left=0, right=0, color="000000"):
+        """ set the pad band size and color as hex value """
+        if top:
+            self.__effects__["padtop"] = top
+        if bottom:
+            self.__effects__["padbottom"] = bottom
+        if left:
+            self.__effects__["padleft"] = left
+        if right:
+            self.__effects__["padright"] = right
+        if color:
+            self.__effects__["padcolor"] = color
+        return self.__effects__.get("padtop"), self.__effects__.get("padbottom"), self.__effects__.get("padleft"), self.__effects__.get("padright"), self.__effects__.get("padcolor")
+
+    def unset_pad(self):
+        del self.__effects__["padtop"]
+        del self.__effects__["padbottom"]
+        del self.__effects__["padleft"]
+        del self.__effects__["padright"]
+
+    def vn(self):
+        """ disable video recording """
+        self.__effects__["vn"] = True
+
+    def unset_vn(self):
+        del self.__effects__["vn"]
+
+    def bitratetolerance(self, bt=None):
+        """ set bitrate tolerance """
+        if bt:
+            self.__effects__["bt"] = "%sk" % int(bt)
+        return self.__effects__.get("bt")
+
+    def unset_bitratetolerance(self):
+        del self.__effects__["bt"]
+
+    def bitraterange(self, minrate=None, maxrate=None):
+        """ set min/max bitrate (bit/s) """
+        if minrate or maxrate and not self.__effects__["bufsize"]:
+            self.__effects__["bufsize"] = 4096
+        if minrate:
+            self.__effects__["minrate"] = minrate
+        if maxrate:
+            self.__effects__["maxrate"] = maxrate
+
+        return self.__effects__.get("minrate"), self.__effects__.get("maxrate")
+
+    def unset_bitraterange(self):
+        del self.__effects__["maxrate"]
+        del self.__effects__["minrate"]
+
+    def bufsize(self, bufsize=4096):
+        """ set buffer size (bits) """
+        self.__effects__["bufsize"] = int(bufsize)
+        return self.__effects__["bufsize"]
+
+    def unset_bufsize(self):
+        del self.__effects__["bufsize"]
+
+    def vcodec(self, vcodec="copy"):
+        """ set video codec """
+        self.__effects__["vcodec"] = vcodec
+        return self.__effects__["vcodec"]
+
+    def unset_vcodec(self):
+        del self.__effects__["vcodec"]
+
+    def sameq(self):
+        """ use same video quality as source """
+        self.__effects__["sameq"] = True
+
+    def unset_sameq(self):
+        del self.__effects__["sameq"]
+
+    def passenc(self, p=1):
+        """ select pass number (1 or 2)"""
+        self.__effects__["pass"] = (int(p) % 3 + 1) % 2 + 1 #!!!
+        return self.__effects__["pass"]
+
+    def unset_passenc(self):
+        del self.__effects__["pass"]
+
+    def pixelformat(self, p=None):
+        """ set pixelformat """
+        if p:
+            self.__effects__["pix_fmt"] = p
+        return self.__effects__.get("pix_fmt")
+
+    def unset_pixelformat(self):
+        del self.__effects__["pix_fmt"]
+
+    #TODO: sws_flag
+
+    def picturesize(self, gop=None):
+        """ set of group pictures size """
+        if gop:
+            self.__effects__["gop"] = int(gop)
+        return self.__effects__.get("gop")
+
+    def unset_picturesize(self):
+        del self.__effects__["gop"]
+
+    def intra(self):
+        """ use only intra frames """
+        self.__effects__["intra"] = True
+
+    def unset_intra(self):
+        del self.__effects__["intra"]
+
+    def vdthreshold(self, vdt=None):
+        """ discard threshold """
+        if vdt:
+            self.__effects__["vdt"] = int(vdt)
+        return self.__effects__.get("vdt")
+
+    def unset_vdthreshold(self):
+        del self.__effects__["vdt"]
+
+    def quantizerscale(self, qscale=None):
+        """ Fixed quantizer scale """
+        if qscale:
+            self.__effects__["qscale"] = int(qscale)
+        return self.__effects__.get("qscale")
+
+    def unset_quantizerscale(self):
+        del self.__effects__["qscale"]
+
+    def quantizerrange(self, qmin=None, qmax=None, qdiff=None):
+        """ define min/max quantizer scale """
+        if qdiff:
+            self.__effects__["qdiff"] = int(qdiff)
+        else:
+            if qmin:
+                self.__effects__["qmin"] = int(qmin)
+            if qmax:
+                self.__effects__["qmax"] = int(qmax)
+        return self.__effects__.get("qmin"), self.__effects__.get("qmax"), self.__effects__.get("qdiff"),
+
+    def unset_quantizerrange(self):
+        del self.__effects__["qdiff"]
+
+    def quantizerblur(self, qblur=None):
+        """ video quantizer scale blur """
+        if qblur:
+            self.__effects__["qblur"] = float(qblur)
+        return self.__effects__.get("qblur")
+
+    def unset_quantizerblur(self):
+        del self.__effects__["qblur"]
+
+    def quantizercompression(self, qcomp=0.5):
+        """ video quantizer scale compression """
+        self.__effects__["qcomp"] = float(qcomp)
+        return self.__effects__["qcomp"]
+
+    def unset_quantizercompression(self):
+        del self.__effects__["qcomp"]
+
+    def lagrangefactor(self, lmin=None, lmax=None):
+        """ min/max lagrange factor """
+        if lmin:
+            self.__effects__["lmin"] = int(lmin)
+        if lmax:
+            self.__effects__["lmax"] = int(lmax)
+        return self.__effects__.get("lmin"), self.__effects__.get("lmax")
+
+    def unset_lagrangefactor(self):
+        del self.__effects__["lmin"]
+        del self.__effects__["lmax"]
+
+    def macroblock(self, mblmin=None, mblmax=None):
+        """ min/max macroblock scale """
+        if mblmin:
+            self.__effects__["mblmin"] = int(mblmin)
+        if mblmax:
+            self.__effects__["mblmax"] = int(mblmax)
+        return self.__effects__.get("mblmin"), self.__effects__.get("mblmax")
+
+    def unset_macroblock(self):
+        del self.__effects__["mblmin"]
+        del self.__effects__["mblmax"]
+
+    #TODO: read man pages !
+
+
+class FFAudioEffect(FFEffect):
+    """
+        Audio effect
+    """
+
+    def __init__(self, inputfile, **args):
+        FFEffect.__init__(self, inputfile, **args)
+
+    def aframes(self, aframes=None):
+        """ set number of audio frames to record """
+        if aframes:
+            self.__effects__["aframes"] = int(aframes)
+        return self.__effects__.get("aframes")
+
+    def unset_aframes(self):
+        del self.__effects__["aframes"]
+
+    def audiosampling(self, ar=44100):
+        """ set audio sampling frequency (Hz)"""
+        self.__effects__["ar"] = int(ar)
+        return self.__effects__["ar"]
+
+    def unset_audiosampling(self):
+        del self.__effects__["ar"]
+
+    def audiobitrate(self, ab=64):
+        """ set audio bitrate (kbit/s)"""
+        self.__effects__["ab"] = int(ab)
+        return self.__effects__["ab"]
+
+    def unset_audiobitrate(self):
+        del self.__effects__["ab"]
+
+    def audiochannels(self, ac=1):
+        """ set number of audio channels """
+        self.__effects__["ac"] = int(ac)
+        return self.__effects__["ac"]
+
+    def unset_audiochannels(self):
+        del self.__effects__["ac"]
+
+    def audiorecording(self):
+        """ disable audio recording """
+        self.__effects__["an"] = True
+
+    def unset_audiorecording(self):
+        del self.__effects__["an"]
+
+    def acodec(self, acodec="copy"):
+        """ select audio codec """
+        self.__effects__["acodec"] = acodec
+        return self.__effects__["acodec"]
+
+    def unset_acodec(self):
+        del self.__effects__["acodec"]
+
+    def newaudio(self):
+        """ add new audio track """
+        self.__effects__["newaudio"] = True
+
+    def unset_newaudio(self):
+        del self.__effects__["newaudio"]