diff --git a/README.md b/README.md index cea52de..e4e46ec 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ pip install ffmpeg-python ``` It's also possible to clone the source and put it on your python path (`$PYTHONPATH`, `sys.path`, etc.): + ```bash $ git clone git@github.com:kkroening/ffmpeg-python.git $ export PYTHONPATH=${PYTHONPATH}:ffmpeg-python @@ -99,6 +100,7 @@ $ python API documentation is automatically generated from python docstrings and hosted on github pages: https://kkroening.github.io/ffmpeg-python/ Alternatively, standard python help is available, such as at the python REPL prompt as follows: + ```python >>> import ffmpeg >>> help(ffmpeg) diff --git a/ffmpeg/_ffmpeg.py b/ffmpeg/_ffmpeg.py index 968e793..1e7690e 100644 --- a/ffmpeg/_ffmpeg.py +++ b/ffmpeg/_ffmpeg.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +from ._utils import basestring + from .nodes import ( filter_operator, GlobalNode, @@ -41,19 +43,29 @@ def merge_outputs(*streams): @filter_operator() -def output(stream, filename, **kwargs): +def output(*streams_and_filename, **kwargs): """Output file URL + Syntax: + `ffmpeg.output(stream1[, stream2, stream3...], filename, **ffmpeg_args)` + + If multiple streams are provided, they are mapped to the same output. + Official documentation: `Synopsis `__ """ - kwargs['filename'] = filename + streams_and_filename = list(streams_and_filename) + if 'filename' not in kwargs: + if not isinstance(streams_and_filename[-1], basestring): + raise ValueError('A filename must be provided') + kwargs['filename'] = streams_and_filename.pop(-1) + streams = streams_and_filename + fmt = kwargs.pop('f', None) if fmt: if 'format' in kwargs: raise ValueError("Can't specify both `format` and `f` kwargs") kwargs['format'] = fmt - return OutputNode(stream, output.__name__, kwargs=kwargs).stream() - + return OutputNode(streams, output.__name__, kwargs=kwargs).stream() __all__ = [ diff --git a/ffmpeg/_run.py b/ffmpeg/_run.py index 4e98b19..804264d 100644 --- a/ffmpeg/_run.py +++ b/ffmpeg/_run.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from .dag import get_outgoing_edges, topo_sort from functools import reduce -from past.builtins import basestring +from ._utils import basestring import copy import operator import subprocess as _subprocess @@ -22,10 +22,6 @@ from .nodes import ( ) -def _get_stream_name(name): - return '[{}]'.format(name) - - def _convert_kwargs_to_cmd_line_args(kwargs): args = [] for k in sorted(kwargs.keys()): @@ -54,11 +50,24 @@ def _get_input_args(input_node): return args +def _format_input_stream_name(stream_name_map, edge): + prefix = stream_name_map[edge.upstream_node, edge.upstream_label] + if not edge.upstream_selector: + suffix = '' + else: + suffix = ':{}'.format(edge.upstream_selector) + return '[{}{}]'.format(prefix, suffix) + + +def _format_output_stream_name(stream_name_map, edge): + return '[{}]'.format(stream_name_map[edge.upstream_node, edge.upstream_label]) + + def _get_filter_spec(node, outgoing_edge_map, stream_name_map): incoming_edges = node.incoming_edges outgoing_edges = get_outgoing_edges(node, outgoing_edge_map) - inputs = [stream_name_map[edge.upstream_node, edge.upstream_label] for edge in incoming_edges] - outputs = [stream_name_map[edge.upstream_node, edge.upstream_label] for edge in outgoing_edges] + inputs = [_format_input_stream_name(stream_name_map, edge) for edge in incoming_edges] + outputs = [_format_output_stream_name(stream_name_map, edge) for edge in outgoing_edges] filter_spec = '{}{}{}'.format(''.join(inputs), node._get_filter(outgoing_edges), ''.join(outputs)) return filter_spec @@ -71,8 +80,8 @@ def _allocate_filter_stream_names(filter_nodes, outgoing_edge_maps, stream_name_ if len(downstreams) > 1: # TODO: automatically insert `splits` ahead of time via graph transformation. raise ValueError('Encountered {} with multiple outgoing edges with same upstream label {!r}; a ' - '`split` filter is probably required'.format(upstream_node, upstream_label)) - stream_name_map[upstream_node, upstream_label] = _get_stream_name('s{}'.format(stream_count)) + '`split` filter is probably required'.format(upstream_node, upstream_label)) + stream_name_map[upstream_node, upstream_label] = 's{}'.format(stream_count) stream_count += 1 @@ -93,11 +102,16 @@ def _get_output_args(node, stream_name_map): if node.name != output.__name__: raise ValueError('Unsupported output node: {}'.format(node)) args = [] - assert len(node.incoming_edges) == 1 - edge = node.incoming_edges[0] - stream_name = stream_name_map[edge.upstream_node, edge.upstream_label] - if stream_name != '[0]': - args += ['-map', stream_name] + + if len(node.incoming_edges) == 0: + raise ValueError('Output node {} has no mapped streams'.format(node)) + + for edge in node.incoming_edges: + # edge = node.incoming_edges[0] + stream_name = _format_input_stream_name(stream_name_map, edge) + if stream_name != '[0]' or len(node.incoming_edges) > 1: + args += ['-map', stream_name] + kwargs = copy.copy(node.kwargs) filename = kwargs.pop('filename') fmt = kwargs.pop('format', None) @@ -119,7 +133,7 @@ def get_args(stream_spec, overwrite_output=False): output_nodes = [node for node in sorted_nodes if isinstance(node, OutputNode)] global_nodes = [node for node in sorted_nodes if isinstance(node, GlobalNode)] filter_nodes = [node for node in sorted_nodes if isinstance(node, FilterNode)] - stream_name_map = {(node, None): _get_stream_name(i) for i, node in enumerate(input_nodes)} + stream_name_map = {(node, None): str(i) for i, node in enumerate(input_nodes)} filter_arg = _get_filter_arg(filter_nodes, outgoing_edge_maps, stream_name_map) args += reduce(operator.add, [_get_input_args(node) for node in input_nodes]) if filter_arg: diff --git a/ffmpeg/_utils.py b/ffmpeg/_utils.py index 9b575a0..20eb1af 100644 --- a/ffmpeg/_utils.py +++ b/ffmpeg/_utils.py @@ -1,8 +1,42 @@ from __future__ import unicode_literals - -from builtins import str -from past.builtins import basestring import hashlib +import sys + +if sys.version_info.major == 2: + # noinspection PyUnresolvedReferences,PyShadowingBuiltins + str = unicode + + +# `past.builtins.basestring` module can't be imported on Python3 in some environments (Ubuntu). +# This code is copy-pasted from it to avoid crashes. +class BaseBaseString(type): + def __instancecheck__(cls, instance): + return isinstance(instance, (bytes, str)) + + def __subclasshook__(cls, thing): + # TODO: What should go here? + raise NotImplemented + + +def with_metaclass(meta, *bases): + class metaclass(meta): + __call__ = type.__call__ + __init__ = type.__init__ + + def __new__(cls, name, this_bases, d): + if this_bases is None: + return type.__new__(cls, name, (), d) + return meta(name, bases, d) + + return metaclass('temporary_class', None, {}) + + +if sys.version_info.major >= 3: + class basestring(with_metaclass(BaseBaseString)): + pass +else: + # noinspection PyUnresolvedReferences,PyCompatibility + from builtins import basestring def _recursive_repr(item): @@ -27,6 +61,7 @@ def get_hash(item): repr_ = _recursive_repr(item).encode('utf-8') return hashlib.md5(repr_).hexdigest() + def get_hash_int(item): return int(get_hash(item), base=16) diff --git a/ffmpeg/_view.py b/ffmpeg/_view.py index d2a945b..ea3c595 100644 --- a/ffmpeg/_view.py +++ b/ffmpeg/_view.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals from builtins import str from .dag import get_outgoing_edges from ._run import topo_sort -import os import tempfile from ffmpeg.nodes import ( @@ -11,7 +10,6 @@ from ffmpeg.nodes import ( get_stream_spec_nodes, InputNode, OutputNode, - Stream, stream_operator, ) @@ -62,9 +60,13 @@ def view(stream_spec, **kwargs): kwargs = {} up_label = edge.upstream_label down_label = edge.downstream_label - if show_labels and (up_label is not None or down_label is not None): + up_selector = edge.upstream_selector + + if show_labels and (up_label is not None or down_label is not None or up_selector is not None): if up_label is None: up_label = '' + if up_selector is not None: + up_label += ":" + up_selector if down_label is None: down_label = '' if up_label != '' and down_label != '': diff --git a/ffmpeg/dag.py b/ffmpeg/dag.py index 3ce3891..335a060 100644 --- a/ffmpeg/dag.py +++ b/ffmpeg/dag.py @@ -42,6 +42,7 @@ class DagNode(object): Again, because nodes are immutable, the string representations should remain constant. """ + def __hash__(self): """Return an integer hash of the node.""" raise NotImplementedError() @@ -69,32 +70,36 @@ class DagNode(object): raise NotImplementedError() -DagEdge = namedtuple('DagEdge', ['downstream_node', 'downstream_label', 'upstream_node', 'upstream_label']) +DagEdge = namedtuple('DagEdge', ['downstream_node', 'downstream_label', 'upstream_node', 'upstream_label', 'upstream_selector']) def get_incoming_edges(downstream_node, incoming_edge_map): edges = [] - for downstream_label, (upstream_node, upstream_label) in list(incoming_edge_map.items()): - edges += [DagEdge(downstream_node, downstream_label, upstream_node, upstream_label)] + for downstream_label, upstream_info in incoming_edge_map.items(): + upstream_node, upstream_label, upstream_selector = upstream_info + edges += [DagEdge(downstream_node, downstream_label, upstream_node, upstream_label, upstream_selector)] return edges def get_outgoing_edges(upstream_node, outgoing_edge_map): edges = [] for upstream_label, downstream_infos in list(outgoing_edge_map.items()): - for (downstream_node, downstream_label) in downstream_infos: - edges += [DagEdge(downstream_node, downstream_label, upstream_node, upstream_label)] + for downstream_info in downstream_infos: + downstream_node, downstream_label, downstream_selector = downstream_info + edges += [DagEdge(downstream_node, downstream_label, upstream_node, upstream_label, downstream_selector)] return edges class KwargReprNode(DagNode): """A DagNode that can be represented as a set of args+kwargs. """ + @property def __upstream_hashes(self): hashes = [] - for downstream_label, (upstream_node, upstream_label) in list(self.incoming_edge_map.items()): - hashes += [hash(x) for x in [downstream_label, upstream_node, upstream_label]] + for downstream_label, upstream_info in self.incoming_edge_map.items(): + upstream_node, upstream_label, upstream_selector = upstream_info + hashes += [hash(x) for x in [downstream_label, upstream_node, upstream_label, upstream_selector]] return hashes @property @@ -152,21 +157,21 @@ def topo_sort(downstream_nodes): sorted_nodes = [] outgoing_edge_maps = {} - def visit(upstream_node, upstream_label, downstream_node, downstream_label): + def visit(upstream_node, upstream_label, downstream_node, downstream_label, downstream_selector=None): if upstream_node in marked_nodes: raise RuntimeError('Graph is not a DAG') if downstream_node is not None: outgoing_edge_map = outgoing_edge_maps.get(upstream_node, {}) outgoing_edge_infos = outgoing_edge_map.get(upstream_label, []) - outgoing_edge_infos += [(downstream_node, downstream_label)] + outgoing_edge_infos += [(downstream_node, downstream_label, downstream_selector)] outgoing_edge_map[upstream_label] = outgoing_edge_infos outgoing_edge_maps[upstream_node] = outgoing_edge_map if upstream_node not in sorted_nodes: marked_nodes.append(upstream_node) for edge in upstream_node.incoming_edges: - visit(edge.upstream_node, edge.upstream_label, edge.downstream_node, edge.downstream_label) + visit(edge.upstream_node, edge.upstream_label, edge.downstream_node, edge.downstream_label, edge.upstream_selector) marked_nodes.remove(upstream_node) sorted_nodes.append(upstream_node) diff --git a/ffmpeg/nodes.py b/ffmpeg/nodes.py index 013025d..e861822 100644 --- a/ffmpeg/nodes.py +++ b/ffmpeg/nodes.py @@ -21,12 +21,14 @@ def _get_types_str(types): class Stream(object): """Represents the outgoing edge of an upstream node; may be used to create more downstream nodes.""" - def __init__(self, upstream_node, upstream_label, node_types): + + def __init__(self, upstream_node, upstream_label, node_types, upstream_selector=None): if not _is_of_types(upstream_node, node_types): raise TypeError('Expected upstream node to be of one of the following type(s): {}; got {}'.format( _get_types_str(node_types), type(upstream_node))) self.node = upstream_node self.label = upstream_label + self.selector = upstream_selector def __hash__(self): return get_hash_int([hash(self.node), hash(self.label)]) @@ -36,9 +38,30 @@ class Stream(object): def __repr__(self): node_repr = self.node.long_repr(include_hash=False) - out = '{}[{!r}] <{}>'.format(node_repr, self.label, self.node.short_hash) + selector = '' + if self.selector: + selector = ':{}'.format(self.selector) + out = '{}[{!r}{}] <{}>'.format(node_repr, self.label, selector, self.node.short_hash) return out + def __getitem__(self, index): + """ + Select a component (audio, video) of the stream. + + Example: + Process the audio and video portions of a stream independently:: + + input = ffmpeg.input('in.mp4') + audio = input[:'a'].filter_("aecho", 0.8, 0.9, 1000, 0.3) + video = input[:'v'].hflip() + out = ffmpeg.output(audio, video, 'out.mp4') + """ + if self.selector is not None: + raise ValueError('Stream already has a selector: {}'.format(self)) + elif not isinstance(index, basestring): + raise TypeError("Expected string index (e.g. 'a'); got {!r}".format(index)) + return self.node.stream(label=self.label, selector=index) + def get_stream_map(stream_spec): if stream_spec is None: @@ -68,6 +91,7 @@ def get_stream_spec_nodes(stream_spec): class Node(KwargReprNode): """Node base""" + @classmethod def __check_input_len(cls, stream_map, min_inputs, max_inputs): if min_inputs is not None and len(stream_map) < min_inputs: @@ -80,44 +104,62 @@ class Node(KwargReprNode): for stream in list(stream_map.values()): if not _is_of_types(stream, incoming_stream_types): raise TypeError('Expected incoming stream(s) to be of one of the following types: {}; got {}' - .format(_get_types_str(incoming_stream_types), type(stream))) + .format(_get_types_str(incoming_stream_types), type(stream))) @classmethod def __get_incoming_edge_map(cls, stream_map): incoming_edge_map = {} for downstream_label, upstream in list(stream_map.items()): - incoming_edge_map[downstream_label] = (upstream.node, upstream.label) + incoming_edge_map[downstream_label] = (upstream.node, upstream.label, upstream.selector) return incoming_edge_map - def __init__(self, stream_spec, name, incoming_stream_types, outgoing_stream_type, min_inputs, max_inputs, args=[], - kwargs={}): + def __init__(self, stream_spec, name, incoming_stream_types, outgoing_stream_type, min_inputs, + max_inputs, args=[], kwargs={}): stream_map = get_stream_map(stream_spec) self.__check_input_len(stream_map, min_inputs, max_inputs) self.__check_input_types(stream_map, incoming_stream_types) incoming_edge_map = self.__get_incoming_edge_map(stream_map) + super(Node, self).__init__(incoming_edge_map, name, args, kwargs) self.__outgoing_stream_type = outgoing_stream_type + self.__incoming_stream_types = incoming_stream_types - def stream(self, label=None): + def stream(self, label=None, selector=None): """Create an outgoing stream originating from this node. More nodes may be attached onto the outgoing stream. """ - return self.__outgoing_stream_type(self, label) + return self.__outgoing_stream_type(self, label, upstream_selector=selector) - def __getitem__(self, label): + def __getitem__(self, item): """Create an outgoing stream originating from this node; syntactic sugar for ``self.stream(label)``. + It can also be used to apply a selector: e.g. ``node[0:'a']`` returns a stream with label 0 and + selector ``'a'``, which is the same as ``node.stream(label=0, selector='a')``. + + Example: + Process the audio and video portions of a stream independently:: + + input = ffmpeg.input('in.mp4') + audio = input[:'a'].filter_("aecho", 0.8, 0.9, 1000, 0.3) + video = input[:'v'].hflip() + out = ffmpeg.output(audio, video, 'out.mp4') """ - return self.stream(label) + if isinstance(item, slice): + return self.stream(label=item.start, selector=item.stop) + else: + return self.stream(label=item) class FilterableStream(Stream): - def __init__(self, upstream_node, upstream_label): - super(FilterableStream, self).__init__(upstream_node, upstream_label, {InputNode, FilterNode}) + def __init__(self, upstream_node, upstream_label, upstream_selector=None): + super(FilterableStream, self).__init__(upstream_node, upstream_label, {InputNode, FilterNode}, + upstream_selector) +# noinspection PyMethodOverriding class InputNode(Node): """InputNode type""" + def __init__(self, name, args=[], kwargs={}): super(InputNode, self).__init__( stream_spec=None, @@ -135,6 +177,7 @@ class InputNode(Node): return os.path.basename(self.kwargs['filename']) +# noinspection PyMethodOverriding class FilterNode(Node): def __init__(self, stream_spec, name, max_inputs=1, args=[], kwargs={}): super(FilterNode, self).__init__( @@ -149,6 +192,7 @@ class FilterNode(Node): ) """FilterNode""" + def _get_filter(self, outgoing_edges): args = self.args kwargs = self.kwargs @@ -173,6 +217,7 @@ class FilterNode(Node): return escape_chars(params_text, '\\\'[],;') +# noinspection PyMethodOverriding class OutputNode(Node): def __init__(self, stream, name, args=[], kwargs={}): super(OutputNode, self).__init__( @@ -181,7 +226,7 @@ class OutputNode(Node): incoming_stream_types={FilterableStream}, outgoing_stream_type=OutputStream, min_inputs=1, - max_inputs=1, + max_inputs=None, args=args, kwargs=kwargs ) @@ -192,10 +237,12 @@ class OutputNode(Node): class OutputStream(Stream): - def __init__(self, upstream_node, upstream_label): - super(OutputStream, self).__init__(upstream_node, upstream_label, {OutputNode, GlobalNode, MergeOutputsNode}) + def __init__(self, upstream_node, upstream_label, upstream_selector=None): + super(OutputStream, self).__init__(upstream_node, upstream_label, {OutputNode, GlobalNode, MergeOutputsNode}, + upstream_selector=upstream_selector) +# noinspection PyMethodOverriding class MergeOutputsNode(Node): def __init__(self, streams, name): super(MergeOutputsNode, self).__init__( @@ -208,6 +255,7 @@ class MergeOutputsNode(Node): ) +# noinspection PyMethodOverriding class GlobalNode(Node): def __init__(self, stream, name, args=[], kwargs={}): super(GlobalNode, self).__init__( @@ -227,6 +275,7 @@ def stream_operator(stream_classes={Stream}, name=None): func_name = name or func.__name__ [setattr(stream_class, func_name, func) for stream_class in stream_classes] return func + return decorator diff --git a/ffmpeg/tests/test_ffmpeg.py b/ffmpeg/tests/test_ffmpeg.py index 318f03d..b59fa88 100644 --- a/ffmpeg/tests/test_ffmpeg.py +++ b/ffmpeg/tests/test_ffmpeg.py @@ -82,21 +82,21 @@ def test_node_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.node) == "input(filename={!r}) <{}>".format('dummy.mp4', in_file.node.short_hash) - assert repr(trim1.node) == "trim(end_frame=20, start_frame=10) <{}>".format(trim1.node.short_hash) - assert repr(trim2.node) == "trim(end_frame=40, start_frame=30) <{}>".format(trim2.node.short_hash) - assert repr(trim3.node) == "trim(end_frame=60, start_frame=50) <{}>".format(trim3.node.short_hash) - assert repr(concatted.node) == "concat(n=3) <{}>".format(concatted.node.short_hash) - assert repr(output.node) == "output(filename={!r}) <{}>".format('dummy2.mp4', output.node.short_hash) + assert repr(in_file.node) == 'input(filename={!r}) <{}>'.format('dummy.mp4', in_file.node.short_hash) + assert repr(trim1.node) == 'trim(end_frame=20, start_frame=10) <{}>'.format(trim1.node.short_hash) + assert repr(trim2.node) == 'trim(end_frame=40, start_frame=30) <{}>'.format(trim2.node.short_hash) + assert repr(trim3.node) == 'trim(end_frame=60, start_frame=50) <{}>'.format(trim3.node.short_hash) + assert repr(concatted.node) == 'concat(n=3) <{}>'.format(concatted.node.short_hash) + assert repr(output.node) == 'output(filename={!r}) <{}>'.format('dummy2.mp4', output.node.short_hash) def test_stream_repr(): in_file = ffmpeg.input('dummy.mp4') - assert repr(in_file) == "input(filename={!r})[None] <{}>".format('dummy.mp4', in_file.node.short_hash) + assert repr(in_file) == 'input(filename={!r})[None] <{}>'.format('dummy.mp4', in_file.node.short_hash) split0 = in_file.filter_multi_output('split')[0] - assert repr(split0) == "split()[0] <{}>".format(split0.node.short_hash) + assert repr(split0) == 'split()[0] <{}>'.format(split0.node.short_hash) dummy_out = in_file.filter_multi_output('dummy')['out'] - assert repr(dummy_out) == "dummy()[{!r}] <{}>".format(dummy_out.label, dummy_out.node.short_hash) + assert repr(dummy_out) == 'dummy()[{!r}] <{}>'.format(dummy_out.label, dummy_out.node.short_hash) def test_get_args_simple(): @@ -147,6 +147,50 @@ def test_get_args_complex_filter(): ] +def test_combined_output(): + i1 = ffmpeg.input(TEST_INPUT_FILE1) + i2 = ffmpeg.input(TEST_OVERLAY_FILE) + out = ffmpeg.output(i1, i2, TEST_OUTPUT_FILE1) + assert out.get_args() == [ + '-i', TEST_INPUT_FILE1, + '-i', TEST_OVERLAY_FILE, + '-map', '[0]', + '-map', '[1]', + TEST_OUTPUT_FILE1 + ] + + +def test_filter_with_selector(): + i = ffmpeg.input(TEST_INPUT_FILE1) + v1 = i['v'].hflip() + a1 = i['a'].filter_('aecho', 0.8, 0.9, 1000, 0.3) + out = ffmpeg.output(a1, v1, TEST_OUTPUT_FILE1) + assert out.get_args() == [ + '-i', TEST_INPUT_FILE1, + '-filter_complex', + '[0:a]aecho=0.8:0.9:1000:0.3[s0];' \ + '[0:v]hflip[s1]', + '-map', '[s0]', '-map', '[s1]', + TEST_OUTPUT_FILE1 + ] + + +def test_get_item_with_bad_selectors(): + input = ffmpeg.input(TEST_INPUT_FILE1) + + with pytest.raises(ValueError) as excinfo: + input['a']['a'] + assert str(excinfo.value).startswith('Stream already has a selector:') + + with pytest.raises(TypeError) as excinfo: + input[:'a'] + assert str(excinfo.value).startswith("Expected string index (e.g. 'a')") + + with pytest.raises(TypeError) as excinfo: + input[5] + assert str(excinfo.value).startswith("Expected string index (e.g. 'a')") + + def _get_complex_filter_asplit_example(): split = (ffmpeg .input(TEST_INPUT_FILE1) @@ -158,8 +202,8 @@ def _get_complex_filter_asplit_example(): return (ffmpeg .concat( - split0.filter_("atrim", start=10, end=20), - split1.filter_("atrim", start=30, end=40), + split0.filter_('atrim', start=10, end=20), + split1.filter_('atrim', start=30, end=40), ) .output(TEST_OUTPUT_FILE1) .overwrite_output()