--- /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"]