diff --git a/.gitignore b/.gitignore index af3cbac7..f0d6df7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .cache +.eggs +.tox/ dist/ ffmpeg/tests/sample_data/dummy2.mp4 -venv +ffmpeg_python.egg-info/ +venv* diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..e2d8f771 --- /dev/null +++ b/.python-version @@ -0,0 +1,5 @@ +3.3.6 +3.4.6 +3.5.3 +3.6.1 +jython-2.7.0 diff --git a/.travis.yml b/.travis.yml index 4442bf1a..f1863cd6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,36 @@ language: python before_install: - - curl -O https://johnvansickle.com/ffmpeg/releases/ffmpeg-3.3.1-64bit-static.tar.xz - - tar Jxf ffmpeg-3.3.1-64bit-static.tar.xz + - > + [ -f ffmpeg-3.3.1-64bit-static/ffmpeg ] || ( + curl -O https://johnvansickle.com/ffmpeg/releases/ffmpeg-3.3.1-64bit-static.tar.xz && + tar Jxf ffmpeg-3.3.1-64bit-static.tar.xz + ) +matrix: + include: + - python: 2.7 + env: + - TOX_ENV=py27 + - python: 3.3 + env: + - TOX_ENV=py33 + - python: 3.4 + env: + - TOX_ENV=py34 + - python: 3.5 + env: + - TOX_ENV=py35 + - python: 3.6 + env: + - TOX_ENV=py36 + - python: pypy + env: + - TOX_ENV=pypy install: - - pip install -r requirements.txt + - pip install tox script: - export PATH=$(readlink -f ffmpeg-3.3.1-64bit-static):$PATH - - py.test + - tox -e $TOX_ENV +cache: + directories: + - .tox + - ffmpeg-3.3.1-64bit-static diff --git a/ffmpeg/__init__.py b/ffmpeg/__init__.py index ff5c0643..1953609d 100644 --- a/ffmpeg/__init__.py +++ b/ffmpeg/__init__.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals from . import _filters, _ffmpeg, _run from ._filters import * from ._ffmpeg import * diff --git a/ffmpeg/_ffmpeg.py b/ffmpeg/_ffmpeg.py index c9c7f194..ba188ecc 100644 --- a/ffmpeg/_ffmpeg.py +++ b/ffmpeg/_ffmpeg.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals from .nodes import ( FilterNode, GlobalNode, @@ -7,12 +8,17 @@ ) -def input(filename): +def input(filename, **kwargs): """Input file URL (ffmpeg ``-i`` option) Official documentation: `Main options `__ """ - return InputNode(input.__name__, filename=filename) + kwargs['filename'] = filename + fmt = kwargs.pop('f', None) + if fmt: + assert 'format' not in kwargs, "Can't specify both `format` and `f` kwargs" + kwargs['format'] = fmt + return InputNode(input.__name__, **kwargs) @operator(node_classes={OutputNode, GlobalNode}) @@ -30,12 +36,17 @@ def merge_outputs(*parent_nodes): @operator(node_classes={InputNode, FilterNode}) -def output(parent_node, filename): +def output(parent_node, filename, **kwargs): """Output file URL Official documentation: `Synopsis `__ """ - return OutputNode([parent_node], output.__name__, filename=filename) + kwargs['filename'] = filename + fmt = kwargs.pop('f', None) + if fmt: + assert 'format' not in kwargs, "Can't specify both `format` and `f` kwargs" + kwargs['format'] = fmt + return OutputNode([parent_node], output.__name__, **kwargs) diff --git a/ffmpeg/_filters.py b/ffmpeg/_filters.py index 9794e2c7..63296484 100644 --- a/ffmpeg/_filters.py +++ b/ffmpeg/_filters.py @@ -1,3 +1,4 @@ +from __future__ import unicode_literals from .nodes import ( FilterNode, operator, diff --git a/ffmpeg/_run.py b/ffmpeg/_run.py index 28a3e46c..2f99a3f2 100644 --- a/ffmpeg/_run.py +++ b/ffmpeg/_run.py @@ -1,3 +1,8 @@ +from __future__ import unicode_literals + +from functools import reduce +from past.builtins import basestring +import copy import operator as _operator import subprocess as _subprocess @@ -18,9 +23,29 @@ def _get_stream_name(name): return '[{}]'.format(name) +def _convert_kwargs_to_cmd_line_args(kwargs): + args = [] + for k in sorted(kwargs.keys()): + v = kwargs[k] + args.append('-{}'.format(k)) + if v: + args.append('{}'.format(v)) + return args + + def _get_input_args(input_node): if input_node._name == input.__name__: - args = ['-i', input_node._kwargs['filename']] + kwargs = copy.copy(input_node._kwargs) + filename = kwargs.pop('filename') + fmt = kwargs.pop('format', None) + video_size = kwargs.pop('video_size', None) + args = [] + if fmt: + args += ['-f', fmt] + if video_size: + args += ['-video_size', '{}x{}'.format(video_size[0], video_size[1])] + args += _convert_kwargs_to_cmd_line_args(kwargs) + args += ['-i', filename] else: assert False, 'Unsupported input node: {}'.format(input_node) return args @@ -74,7 +99,13 @@ def _get_output_args(node, stream_name_map): if stream_name != '[0]': args += ['-map', stream_name] if node._name == output.__name__: - args += [node._kwargs['filename']] + kwargs = copy.copy(node._kwargs) + filename = kwargs.pop('filename') + fmt = kwargs.pop('format', None) + if fmt: + args += ['-f', fmt] + args += _convert_kwargs_to_cmd_line_args(kwargs) + args += [filename] else: assert False, 'Unsupported output node: {}'.format(node) return args diff --git a/ffmpeg/nodes.py b/ffmpeg/nodes.py index 61c9ff07..c6359e25 100644 --- a/ffmpeg/nodes.py +++ b/ffmpeg/nodes.py @@ -1,3 +1,6 @@ +from __future__ import unicode_literals + +from builtins import object import hashlib import json @@ -18,15 +21,18 @@ def __repr__(self): formatted_props += ['{}={!r}'.format(key, self._kwargs[key]) for key in sorted(self._kwargs)] return '{}({})'.format(self._name, ','.join(formatted_props)) + def __hash__(self): + return int(self._hash, base=16) + def __eq__(self, other): return self._hash == other._hash def _update_hash(self): props = {'args': self._args, 'kwargs': self._kwargs} - my_hash = hashlib.md5(json.dumps(props, sort_keys=True)).hexdigest() + my_hash = hashlib.md5(json.dumps(props, sort_keys=True).encode('utf-8')).hexdigest() parent_hashes = [parent._hash for parent in self._parents] hashes = parent_hashes + [my_hash] - self._hash = hashlib.md5(','.join(hashes)).hexdigest() + self._hash = hashlib.md5(','.join(hashes).encode('utf-8')).hexdigest() class InputNode(Node): diff --git a/ffmpeg/tests/test_ffmpeg.py b/ffmpeg/tests/test_ffmpeg.py index 7e38521d..f2c05215 100644 --- a/ffmpeg/tests/test_ffmpeg.py +++ b/ffmpeg/tests/test_ffmpeg.py @@ -1,8 +1,9 @@ -from ffmpeg.nodes import operator, FilterNode +from __future__ import unicode_literals import ffmpeg import os import pytest import subprocess +import random TEST_DIR = os.path.dirname(__file__) @@ -72,12 +73,12 @@ def test_repr(): trim3 = ffmpeg.trim(in_file, start_frame=50, end_frame=60) concatted = ffmpeg.concat(trim1, trim2, trim3) output = ffmpeg.output(concatted, 'dummy2.mp4') - assert repr(in_file) == "input(filename='dummy.mp4')" + assert repr(in_file) == "input(filename={!r})".format('dummy.mp4') assert repr(trim1) == "trim(end_frame=20,start_frame=10)" assert repr(trim2) == "trim(end_frame=40,start_frame=30)" assert repr(trim3) == "trim(end_frame=60,start_frame=50)" assert repr(concatted) == "concat(n=3)" - assert repr(output) == "output(filename='dummy2.mp4')" + assert repr(output) == "output(filename={!r})".format('dummy2.mp4') def test_get_args_simple(): @@ -167,3 +168,42 @@ def test_custom_filter_fluent(): '-map', '[v0]', 'dummy2.mp4' ] + + +def test_pipe(): + width = 32 + height = 32 + frame_size = width * height * 3 # 3 bytes for rgb24 + frame_count = 10 + start_frame = 2 + + out = (ffmpeg + .input('pipe:0', format='rawvideo', pixel_format='rgb24', video_size=(width, height), framerate=10) + .trim(start_frame=start_frame) + .output('pipe:1', format='rawvideo') + ) + + args = out.get_args() + assert args == [ + '-f', 'rawvideo', + '-video_size', '{}x{}'.format(width, height), + '-framerate', '10', + '-pixel_format', 'rgb24', + '-i', 'pipe:0', + '-filter_complex', + '[0]trim=start_frame=2[v0]', + '-map', '[v0]', + '-f', 'rawvideo', + 'pipe:1' + ] + + cmd = ['ffmpeg'] + args + p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + in_data = bytes(bytearray([random.randint(0,255) for _ in range(frame_size * frame_count)])) + p.stdin.write(in_data) # note: this could block, in which case need to use threads + p.stdin.close() + + out_data = p.stdout.read() + assert len(out_data) == frame_size * (frame_count - start_frame) + assert out_data == in_data[start_frame*frame_size:] diff --git a/requirements.txt b/requirements.txt index 55ff9de5..b5e8ae3e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ +future pytest +pytest-runner sphinx +tox diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..b7e47898 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[aliases] +test=pytest diff --git a/setup.py b/setup.py index d8b63344..0e11dabc 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ -from distutils.core import setup -from ffmpeg._filters import __all__ as filter_names +from setuptools import setup from textwrap import dedent import subprocess @@ -61,18 +60,34 @@ def get_current_commit_hash(): 'wrapper', ] -keywords = misc_keywords + file_formats + filter_names +keywords = misc_keywords + file_formats setup( - name = 'ffmpeg-python', - packages = ['ffmpeg'], - version = '0.1.5', - description = 'Python bindings for FFmpeg - with support for complex filtering', - author = 'Karl Kroening', - author_email = 'karlk@kralnet.us', - url = 'https://github.com/kkroening/ffmpeg-python', - download_url = download_url, - classifiers = [], - keywords = keywords, - long_description = long_description, + name='ffmpeg-python', + packages=['ffmpeg'], + setup_requires=['pytest-runner'], + tests_require=['pytest'], + version='0.1.5', + description='Python bindings for FFmpeg - with support for complex filtering', + author='Karl Kroening', + author_email='karlk@kralnet.us', + url='https://github.com/kkroening/ffmpeg-python', + download_url=download_url, + keywords=keywords, + long_description=long_description, + install_requires=['future'], + classifiers=[ + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + ], ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..daad3a40 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +# Tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = py27, py33, py34, py35, py36, pypy + +[testenv] +commands = py.test -vv +deps = + future + pytest