8000 Merge pull request #85 from kkroening/inout · Powercoder64/ffmpeg-python@e6fd3ff · GitHub
[go: up one dir, main page]

Skip to content

Commit e6fd3ff

Browse files
authored
Merge pull request kkroening#85 from kkroening/inout
Add input/output support in `run` command; update docs
2 parents 57b8f9f + 6a2d338 commit e6fd3ff

File tree

9 files changed

+150
-50
lines changed

9 files changed

+150
-50
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ dist/
55
ffmpeg/tests/sample_data/out*.mp4
66
ffmpeg_python.egg-info/
77
venv*
8+
build/

ffmpeg/_ffmpeg.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
def input(filename, **kwargs):
1717
"""Input file URL (ffmpeg ``-i`` option)
1818
19+
Any supplied kwargs are passed to ffmpeg verbatim (e.g. ``t=20``,
20+
``f='mp4'``, ``acodec='pcm'``, etc.).
21+
22+
To tell ffmpeg to read from stdin, use ``pipe:`` as the filename.
23+
1924
Official documentation: `Main options <https://ffmpeg.org/ffmpeg.html#Main-options>`__
2025
"""
2126
kwargs['filename'] = filename
@@ -57,7 +62,13 @@ def output(*streams_and_filename, **kwargs):
5762
Syntax:
5863
`ffmpeg.output(stream1[, stream2, stream3...], filename, **ffmpeg_args)`
5964
60-
If multiple streams are provided, they are mapped to the same output.
65+
If multiple streams are provided, they are mapped to the same
66+
output.
67+
68+
Any supplied kwargs are passed to ffmpeg verbatim (e.g. ``t=20``,
69+
``f='mp4'``, ``acodec='pcm'``, etc.).
70+
71+
To tell ffmpeg to write to stdout, use ``pipe:`` as the filename.
6172
6273
Official documentation: `Synopsis <https://ffmpeg.org/ffmpeg.html#Synopsis>`__
6374
"""

ffmpeg/_probe.py

100755100644
Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
11
import json
22
import subprocess
3-
4-
5-
class ProbeException(Exception):
6-
def __init__(self, stderr_output):
7-
super(ProbeException, self).__init__('ffprobe error')
8-
self.stderr_output = stderr_output
3+
from ._run import Error
94

105

116
def probe(filename):
127
"""Run ffprobe on the specified file and return a JSON representation of the output.
138
149
Raises:
15-
ProbeException: if ffprobe returns a non-zero exit code, a ``ProbeException`` is returned with a generic error
16-
message. The stderr output can be retrieved by accessing the ``stderr_output`` property of the exception.
10+
:class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code,
11+
an :class:`Error` is returned with a generic error message.
12+
The stderr output can be retrieved by accessing the
13+
``stderr`` property of the exception.
1714
"""
1815
args = ['ffprobe', '-show_format', '-show_streams', '-of', 'json', filename]
1916
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
2017
out, err = p.communicate()
2118
if p.returncode != 0:
22-
raise ProbeException(err)
19+
raise Error('ffprobe', out, err)
2320
return json.loads(out.decode('utf-8'))
2421

2522

2623
__all__ = [
2724
'probe',
28-
'ProbeException',
2925
]

ffmpeg/_run.py

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from __future__ import unicode_literals
22

3-
from builtins import str
4-
from past.builtins import basestring
53
from .dag import get_outgoing_edges, topo_sort
6-
from functools import reduce
74
from ._utils import basestring
5+
from builtins import str
6+
from functools import reduce
7+
from past.builtins import basestring
88
import copy
99
import operator
10-
import subprocess as _subprocess
10+
import subprocess
1111

1212
from ._ffmpeg import (
1313
input,
@@ -23,6 +23,13 @@
2323
)
2424

2525

26+
class Error(Exception):
27+
def __init__(self, cmd, stdout, stderr):
28+
super(Error, self).__init__('{} error (see stderr output for detail)'.format(cmd))
29+
self.stdout = stdout
30+
self.stderr = stderr
31+
32+
2633
def _convert_kwargs_to_cmd_line_args(kwargs):
2734
args = []
2835
for k in sorted(kwargs.keys()):
@@ -80,8 +87,9 @@ def _allocate_filter_stream_names(filter_nodes, outgoing_edge_maps, stream_name_
8087
for upstream_label, downstreams in list(outgoing_edge_map.items()):
8188
if len(downstreams) > 1:
8289
# TODO: automatically insert `splits` ahead of time via graph transformation.
83-
raise ValueError('Encountered {} with multiple outgoing edges with same upstream label {!r}; a '
84-
'`split` filter is probably required'.format(upstream_node, upstream_label))
90+
raise ValueError(
91+
'Encountered {} with multiple outgoing edges with same upstream label {!r}; a '
92+
'`split` filter is probably required'.format(upstream_node, upstream_label))
8593
stream_name_map[upstream_node, upstream_label] = 's{}'.format(stream_count)
8694
stream_count += 1
8795

@@ -122,7 +130,7 @@ def _get_output_args(node, stream_name_map):
122130

123131
@output_operator()
124132
def get_args(stream_spec, overwrite_output=False):
125-
"""Get command-line arguments for ffmpeg."""
133+
"""Build command-line arguments to be passed to ffmpeg."""
126134
nodes = get_stream_spec_nodes(stream_spec)
127135
args = []
128136
# TODO: group nodes together, e.g. `-i somefile -r somerate`.
@@ -144,27 +152,57 @@ def get_args(stream_spec, overwrite_output=False):
144152

145153

146154
@output_operator()
147-
def compile(stream_spec, cmd='ffmpeg', **kwargs):
148-
"""Build command-line for ffmpeg."""
155+
def compile(stream_spec, cmd='ffmpeg', overwrite_output=False):
156+
"""Build command-line for invoking ffmpeg.
157+
158+
The :meth:`run` function uses this to build the commnad line
159+
arguments and should work in most cases, but calling this function
160+
directly is useful for debugging or if you need to invoke ffmpeg
161+
manually for whatever reason.
162+
163+
This is the same as calling :meth:`get_args` except that it also
164+
includes the ``ffmpeg`` command as the first argument.
165+
"""
149166
if isinstance(cmd, basestring):
150167
cmd = [cmd]
151168
elif type(cmd) != list:
152169
cmd = list(cmd)
153-
return cmd + get_args(stream_spec, **kwargs)
170+
return cmd + get_args(stream_spec, overwrite_output=overwrite_output)
154171

155172

156173
@output_operator()
157-
def run(stream_spec, cmd='ffmpeg', **kwargs):
158-
"""Run ffmpeg on node graph.
174+
def run(
175+
stream_spec, cmd='ffmpeg', capture_stdout=False, capture_stderr=False, input=None,
176+
quiet=False, overwrite_output=False):
177+
"""Ivoke ffmpeg for the supplied node graph.
159178
160179
Args:
161-
**kwargs: keyword-arguments passed to ``get_args()`` (e.g. ``overwrite_output=True``).
180+
capture_stdout: if True, capture stdout (to be used with
181+
``pipe:`` ffmpeg outputs).
182+
capture_stderr: if True, capture stderr.
183+
quiet: shorthand for setting ``capture_stdout`` and ``capture_stderr``.
184+
input: text to be sent to stdin (to be used with ``pipe:``
185+
ffmpeg inputs)
186+
**kwargs: keyword-arguments passed to ``get_args()`` (e.g.
187+
``overwrite_output=True``).
188+
189+
Returns: (out, err) tuple containing captured stdout and stderr data.
162190
"""
163-
_subprocess.check_call(compile(stream_spec, cmd, **kwargs))
191+
args = compile(stream_spec, cmd, overwrite_output=overwrite_output)
192+
stdin_stream = subprocess.PIPE if input else None
193+
stdout_stream = subprocess.PIPE if capture_stdout or quiet else None
194+
stderr_stream = subprocess.PIPE if capture_stderr or quiet else None
195+
p = subprocess.Popen(args, stdin=stdin_stream, stdout=stdout_stream, stderr=stderr_stream)
196+
out, err = p.communicate(input)
197+
retcode = p.poll()
198+
if retcode:
199+
raise Error('ffmpeg', out, err)
200+
return out, err
164201

165202

166203
__all__ = [
167204
'compile',
205+
'Error',
168206
'get_args',
169207
'run',
170208
]

ffmpeg/tests/test_ffmpeg.py

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def test_stream_repr():
101101
assert repr(dummy_out) == 'dummy()[{!r}] <{}>'.format(dummy_out.label, dummy_out.node.short_hash)
102102

103103

104-
def test_get_args_simple():
104+
def test__get_args__simple():
105105
out_file = ffmpeg.input('dummy.mp4').output('dummy2.mp4')
106106
assert out_file.get_args() == ['-i', 'dummy.mp4', 'dummy2.mp4']
107107

@@ -111,6 +111,10 @@ def test_global_args():
111111
assert out_file.get_args() == ['-i', 'dummy.mp4', 'dummy2.mp4', '-progress', 'someurl']
112112

113113

114+
def _get_simple_example():
115+
return ffmpeg.input(TEST_INPUT_FILE1).output(TEST_OUTPUT_FILE1)
116+
117+
114118
def _get_complex_filter_example():
115119
split = (ffmpeg
116120
.input(TEST_INPUT_FILE1)
@@ -134,7 +138,7 @@ def _get_complex_filter_example():
134138
)
135139

136140

137-
def test_get_args_complex_filter():
141+
def test__get_args__complex_filter():
138142
out = _get_complex_filter_example()
139143
args = ffmpeg.get_args(out)
140144
assert args == ['-i', TEST_INPUT_FILE1,
@@ -305,41 +309,81 @@ def _get_drawtext_text_repr(text):
305309
# subprocess.check_call(['ffmpeg', '-version'])
306310

307311

308-
def test_compile():
312+
def test__compile():
309313
out_file = ffmpeg.input('dummy.mp4').output('dummy2.mp4')
310314
assert out_file.compile() == ['ffmpeg', '-i', 'dummy.mp4', 'dummy2.mp4']
311315
assert out_file.compile(cmd='ffmpeg.old') == ['ffmpeg.old', '-i', 'dummy.mp4', 'dummy2.mp4']
312316

313317

314-
def test_run():
318+
def test__run():
315319
stream = _get_complex_filter_example()
316-
ffmpeg.run(stream)
317-
318-
319-
def test_run_multi_output():
320+
out, err = ffmpeg.run(stream)
321+
assert out is None
322+
assert err is None
323+
324+
325+
@pytest.mark.parametrize('capture_stdout', [True, False])
326+
@pytest.mark.parametrize('capture_stderr', [True, False])
327+
def test__run__capture_out(mocker, capture_stdout, capture_stderr):
328+
mocker.patch.object(ffmpeg._run, 'compile', return_value=['echo', 'test'])
329+
stream = _get_simple_example()
330+
out, err = ffmpeg.run(stream, capture_stdout=capture_stdout, capture_stderr=capture_stderr)
331+
if capture_stdout:
332+
assert out == 'test\n'.encode()
333+
else:
334+
assert out is None
335+
if capture_stderr:
336+
assert err == ''.encode()
337+
else:
338+
assert err is None
339+
340+
341+
def test__run__input_output(mocker):
342+
mocker.patch.object(ffmpeg._run, 'compile', return_value=['cat'])
343+
stream = _get_simple_example()
344+
out, err = ffmpeg.run(stream, input='test'.encode(), capture_stdout=True)
345+
assert out == 'test'.encode()
346+
assert err is None
347+
348+
349+
@pytest.mark.parametrize('capture_stdout', [True, False])
350+
@pytest.mark.parametrize('capture_stderr', [True, False])
351+
def test__run__error(mocker, capture_stdout, capture_stderr):
352+
mocker.patch.object(ffmpeg._run, 'compile', return_value=['ffmpeg'])
353+
stream = _get_complex_filter_example()
354+
with pytest.raises(ffmpeg.Error) as excinfo:
355+
out, err = ffmpeg.run(stream, capture_stdout=capture_stdout, capture_stderr=capture_stderr)
356+
assert str(excinfo.value) == 'ffmpeg error (see stderr output for detail)'
357+
out = excinfo.value.stdout
358+
err = excinfo.value.stderr
359+
if capture_stdout:
360+
assert out == ''.encode()
361+
else:
362+
assert out is None
363+
if capture_stderr:
364+
assert err.decode().startswith('ffmpeg version')
365+
else:
366+
assert err is None
367+
368+
369+
def test__run__multi_output():
320370
in_ = ffmpeg.input(TEST_INPUT_FILE1)
321371
out1 = in_.output(TEST_OUTPUT_FILE1)
322372
out2 = in_.output(TEST_OUTPUT_FILE2)
323373
ffmpeg.run([out1, out2], overwrite_output=True)
324374

325375

326-
def test_run_dummy_cmd():
376+
def test__run__dummy_cmd():
327377
stream = _get_complex_filter_example()
328378
ffmpeg.run(stream, cmd='true')
329379

330380

331-
def test_run_dummy_cmd_list():
381+
def test__run__dummy_cmd_list():
332382
stream = _get_complex_filter_example()
333383
ffmpeg.run(stream, cmd=['true', 'ignored'])
334384

335385

336-
def test_run_failing_cmd():
337-
stream = _get_complex_filter_example()
338-
with pytest.raises(subprocess.CalledProcessError):
339-
ffmpeg.run(stream, cmd='false')
340-
341-
342-
def test_custom_filter():
386+
def test__filter__custom():
343387
stream = ffmpeg.input('dummy.mp4')
344388
stream = ffmpeg.filter_(stream, 'custom_filter', 'a', 'b', kwarg1='c')
345389
stream = ffmpeg.output(stream, 'dummy2.mp4')
@@ -351,7 +395,7 @@ def test_custom_filter():
351395
]
352396

353397

354-
def test_custom_filter_fluent():
398+
def test__filter__custom_fluent():
355399
stream = (ffmpeg
356400
.input('dummy.mp4')
357401
.filter_('custom_filter', 'a', 'b', kwarg1='c')
@@ -365,7 +409,7 @@ def test_custom_filter_fluent():
365409
]
366410

367411

368-
def test_merge_outputs():
412+
def test__merge_outputs():
369413
in_ = ffmpeg.input('in.mp4')
370414
out1 = in_.output('out1.mp4')
371415
out2 = in_.output('out2.mp4')
@@ -441,14 +485,14 @@ def test_pipe():
441485
assert out_data == in_data[start_frame*frame_size:]
442486

443487

444-
def test_ffprobe():
488+
def test__probe():
445489
data = ffmpeg.probe(TEST_INPUT_FILE1)
446490
assert set(data.keys()) == {'format', 'streams'}
447491
assert data['format']['duration'] == '7.036000'
448492

449493

450-
def test_ffprobe_exception():
451-
with pytest.raises(ffmpeg.ProbeException) as excinfo:
494+
def test__probe__exception():
495+
with pytest.raises(ffmpeg.Error) as excinfo:
452496
ffmpeg.probe(BOGUS_INPUT_FILE)
453-
assert str(excinfo.value) == 'ffprobe error'
454-
assert b'No such file or directory' in excinfo.value.stderr_output
497+
assert str(excinfo.value) == 'ffprobe error (see stderr output for detail)'
498+
assert 'No such file or directory'.encode() in excinfo.value.stderr

requirements-base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
future
22
pytest
3+
pytest-mock
34
pytest-runner
45
sphinx
56
tox

requirements.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
alabaster==0.7.10
2+
apipkg==1.4
23
Babel==2.5.1
34
certifi==2017.7.27.1
45
chardet==3.0.4
56
docutils==0.14
7+
execnet==1.5.0
8+
funcsigs==1.0.2
69
future==0.16.0
710
idna==2.6
811
imagesize==0.7.1
912
Jinja2==2.9.6
1013
MarkupSafe==1.0
14+
mock==2.0.0
15+
pbr==4.0.3
1116
pluggy==0.5.2
1217
py==1.4.34
1318
Pygments==2.2.0
1419
pytest==3.2.3
20+
pytest-forked==0.2
21+
pytest-mock==1.10.0
1522
pytest-runner==3.0
23+
pytest-xdist==1.22.2
1624
pytz==2017.3
1725
requests==2.18.4
1826
six==1.11.0

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
name='ffmpeg-python',
5858
packages=['ffmpeg'],
5959
setup_requires=['pytest-runner'],
60-
tests_require=['pytest'],
60+
tests_require=['pytest', 'pytest-mock'],
6161
version=version,
6262
description='Python bindings for FFmpeg - with support for complex filtering',
6363
author='Karl Kroening',

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ commands = py.test -vv
1111
deps =
1212
future
1313
pytest
14+
pytest-mock

0 commit comments

Comments
 (0)
0