Skip to content

Commit 06d0e43

Browse files
committed
Merge branch 'feat/2-pass-write'
2 parents 75c0a07 + 3462734 commit 06d0e43

6 files changed

Lines changed: 265 additions & 12 deletions

File tree

src/ffmpegio/ffmpeg.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,15 +172,26 @@ def inputs2args(inputs):
172172
for url, opts in inputs:
173173
if opts:
174174
args.extend(opts2args(opts, finalize_input))
175-
args.extend(["-i", url])
175+
args.extend(
176+
[
177+
"-i",
178+
url
179+
if url is not None
180+
else "/dev/null"
181+
if _os.name != "nt"
182+
else "NUL",
183+
]
184+
)
176185
return args
177186

178187
def outputs2args(outputs):
179188
args = []
180189
for url, opts in outputs:
181190
if opts:
182191
args.extend(opts2args(opts, finalize_output))
183-
args.append(url)
192+
args.append(
193+
url if url is not None else "/dev/null" if _os.name != "nt" else "NUL"
194+
)
184195
return args
185196

186197
args = [

src/ffmpegio/ffmpegprocess.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919
2020
"""
2121

22+
from os import path
2223
from threading import Thread as _Thread
2324
import subprocess as _sp
25+
from copy import deepcopy
2426
from subprocess import PIPE, DEVNULL
27+
from tempfile import TemporaryDirectory
2528
from .ffmpeg import exec, parse
2629
from .threading import ProgressMonitorThread
2730
from .configure import move_global_options
@@ -123,6 +126,7 @@ class instance. If output is piped, :code:`stdout` is default to :code:`ffmpegi
123126
def __init__(
124127
self,
125128
ffmpeg_args,
129+
*,
126130
hide_banner=True,
127131
progress=None,
128132
overwrite=None,
@@ -251,6 +255,7 @@ def send_signal(self, sig: int):
251255

252256
def run(
253257
ffmpeg_args,
258+
*,
254259
hide_banner=True,
255260
progress=None,
256261
overwrite=None,
@@ -331,3 +336,140 @@ def run(
331336
ret.stderr = ret.stderr.decode("utf-8")
332337

333338
return ret
339+
340+
341+
def run_two_pass(
342+
ffmpeg_args,
343+
pass1_omits=None,
344+
pass1_extras=None,
345+
overwrite=None,
346+
stdin=None,
347+
**other_run_kwargs,
348+
):
349+
"""run FFmpeg subprocess with standard pipes with a single transaction twice for 2-pass encoding
350+
351+
:param ffmpeg_args: FFmpeg argument options
352+
:type ffmpeg_args: dict
353+
:param pass1_omits: per-file list of output arguments to ignore in pass 1. If not applicable to every
354+
output file, use a nested dict with int keys to specify which output,
355+
defaults to None (remove 'c:a' or 'acodec').
356+
:type pass1_omits: seq(seq(str)) or dict(int:seq(str)) optional
357+
:param pass1_extras: per-file list of additional output arguments to include in pass 1. If it does
358+
not apply to every output files, use a nested dict with int keys to specify
359+
which output, defaults to None (add 'an' if `pass1_omits` also None)
360+
:type pass1_extras: seq(dict(str)) or dict(int:dict(str)), optional
361+
:param hide_banner: False to output ffmpeg banner in stderr, defaults to True
362+
:type hide_banner: bool, optional
363+
:param progress: progress callback function, defaults to None. This function
364+
takes two arguments:
365+
366+
progress(data:dict, done:bool) -> None
367+
368+
:type progress: callable object, optional
369+
:param overwrite: True to overwrite if output url exists, defaults to None
370+
(auto-select)
371+
:type overwrite: bool, optional
372+
:param capture_log: True to capture log messages on stderr, False to send
373+
logs to console, defaults to None (no show/capture)
374+
:type capture_log: bool, optional
375+
:param stdin: source file object, defaults to None
376+
:type stdin: readable file-like object, optional
377+
:param stderr: file to log ffmpeg messages, defaults to None
378+
:type stderr: writable file-like object, optional
379+
:param input: input data buffer must be given if FFmpeg is configured to receive
380+
data stream from Python. It must be bytes convertible to bytes.
381+
:type input: bytes-convertible object, optional
382+
:param \\**other_popen_kwargs: other keyword arguments of :py:class:`Popen`, defaults to {}
383+
:type \\**other_popen_kwargs: dict, optional
384+
:rparam: completed process
385+
:rtype: subprocess.CompleteProcess
386+
"""
387+
388+
# TODO allow multiple stream 2-pass encoding
389+
# TODO add additional arguments to specify which output file
390+
# TODO add additional arguments to control which output option to be added or dropped during 1st pass
391+
392+
from_stream = stdin is not None
393+
if from_stream:
394+
try:
395+
assert stdin.seekable()
396+
except:
397+
raise ValueError("stdin must be seekable")
398+
399+
ffmpeg_args["outputs"] = list(ffmpeg_args["outputs"])
400+
401+
# ref: https://trac.ffmpeg.org/wiki/Encode/H.264#twopass
402+
pass1_args = deepcopy(ffmpeg_args)
403+
404+
def mod_pass1_outopts(i, opts):
405+
opts = opts or {}
406+
opts["f"] = "null"
407+
opts["pass"] = 1
408+
409+
def omit_opt(k):
410+
try:
411+
del opts[k]
412+
except:
413+
pass
414+
415+
if pass1_omits is None:
416+
omit_opt("c:a")
417+
omit_opt("acodec")
418+
else:
419+
try:
420+
for k in pass1_omits[i]:
421+
omit_opt(k)
422+
except:
423+
pass
424+
425+
if pass1_extras is not None:
426+
try:
427+
for k, v in pass1_extras.items():
428+
opts[k] = v
429+
except:
430+
pass
431+
elif pass1_omits is None:
432+
opts["an"] = None
433+
434+
return None, opts
435+
436+
pass1_args["outputs"] = [
437+
mod_pass1_outopts(i, o[1]) for i, o in enumerate(pass1_args["outputs"])
438+
]
439+
pass1_opts = pass1_args["global_options"] = pass1_args["global_options"] or {}
440+
pass1_opts["y"] = None
441+
try:
442+
del pass1_opts["n"]
443+
except:
444+
pass
445+
446+
def mod_pass2_outopts(url, opts):
447+
try:
448+
opts["pass"] = 2
449+
return url, opts
450+
except:
451+
return (url, {"pass": 2})
452+
453+
ffmpeg_args["outputs"] = [mod_pass2_outopts(*o) for o in ffmpeg_args["outputs"]]
454+
455+
with TemporaryDirectory() as tmpdir:
456+
if "passlogfile" not in ffmpeg_args["outputs"][0][1]:
457+
ffmpeg_args["outputs"][0][1]["passlogfile"] = pass1_args["outputs"][0][1][
458+
"passlogfile"
459+
] = path.join(tmpdir, "ffmpeg2pass")
460+
461+
if stdin is not None:
462+
pos = stdin.tell()
463+
464+
run(pass1_args, **other_run_kwargs)
465+
466+
if stdin is not None:
467+
stdin.seek(pos)
468+
469+
ret = run(ffmpeg_args, overwrite=overwrite, **other_run_kwargs)
470+
471+
# split log lines
472+
if ret.stderr is not None:
473+
ret.stderr = ret.stderr.decode("utf-8")
474+
475+
return ret

src/ffmpegio/transcode.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22
from .utils.log import FFmpegError
33

44

5-
def transcode(input_url, output_url, progress=None, overwrite=None, show_log=None, **options):
5+
def transcode(
6+
input_url,
7+
output_url,
8+
progress=None,
9+
overwrite=None,
10+
show_log=None,
11+
two_pass=False,
12+
pass1_omits=None,
13+
pass1_extras=None,
14+
**options
15+
):
616
"""Transcode a media file to another format/encoding
717
818
:param input_url: url/path of the input media file
@@ -18,6 +28,13 @@ def transcode(input_url, output_url, progress=None, overwrite=None, show_log=Non
1828
defaults to None (no show/capture)
1929
Ignored if stream format must be retrieved automatically.
2030
:type show_log: bool, optional
31+
:param two_pass: True to encode in 2-pass
32+
:param pass1_omits: list of output arguments to ignore in pass 1, defaults to
33+
None (removes 'c:a' or 'acodec')
34+
:type pass1_omits: seq(str), optional
35+
:param pass1_extras: list of additional output arguments to include in pass 1,
36+
defaults to None (add 'an' if `pass1_omits` also None)
37+
:type pass1_extras: dict(int:dict(str)), optional
2138
:param \\**options: FFmpeg options. For output and global options, use FFmpeg
2239
option names as is. For input options, prepend "input_" to
2340
the option name. For example, input_r=2000 to force the
@@ -38,14 +55,24 @@ def transcode(input_url, output_url, progress=None, overwrite=None, show_log=Non
3855
configure.add_url(args, "input", input_url, input_options)[1][1]
3956
configure.add_url(args, "output", output_url, options)
4057

41-
pout = ffmpegprocess.run(
58+
kwargs = (
59+
{
60+
"pass1_omits": None if pass1_omits is None else [pass1_omits],
61+
"pass1_extras": None if pass1_extras is None else [pass1_extras],
62+
}
63+
if two_pass
64+
else {}
65+
)
66+
67+
pout = (ffmpegprocess.run_two_pass if two_pass else ffmpegprocess.run)(
4268
args,
4369
progress=progress,
4470
overwrite=overwrite,
4571
capture_log=False if show_log else True,
4672
stdin=stdin,
4773
stdout=stdout,
4874
input=input,
75+
**kwargs,
4976
)
5077
if pout.returncode:
5178
raise FFmpegError(pout.stderr, show_log)

src/ffmpegio/video.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from copy import deepcopy
12
import numpy as np
23

34
from . import ffmpegprocess, utils, configure, FFmpegError, probe
@@ -196,7 +197,18 @@ def read(url, progress=None, show_log=None, **options):
196197
)
197198

198199

199-
def write(url, rate_in, data, progress=None, overwrite=None, show_log=None, **options):
200+
def write(
201+
url,
202+
rate_in,
203+
data,
204+
progress=None,
205+
overwrite=None,
206+
show_log=None,
207+
two_pass=False,
208+
pass1_omits=None,
209+
pass1_extras=None,
210+
**options,
211+
):
200212
"""Write Numpy array to a video file
201213
202214
:param url: URL of the video file to write.
@@ -213,6 +225,11 @@ def write(url, rate_in, data, progress=None, overwrite=None, show_log=None, **op
213225
:param show_log: True to show FFmpeg log messages on the console,
214226
defaults to None (no show/capture)
215227
:type show_log: bool, optional
228+
:param two_pass: True to encode in 2-pass
229+
:param pass1_omits: list of output arguments to ignore in pass 1, defaults to None
230+
:type pass1_omits: seq(str), optional
231+
:param pass1_extras: list of additional output arguments to include in pass 1, defaults to None
232+
:type pass1_extras: dict(int:dict(str)), optional
216233
:param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`)
217234
:type \\**options: dict, optional
218235
"""
@@ -229,12 +246,22 @@ def write(url, rate_in, data, progress=None, overwrite=None, show_log=None, **op
229246
)
230247
configure.add_url(ffmpeg_args, "output", url, options)
231248

232-
ffmpegprocess.run(
249+
kwargs = (
250+
{
251+
"pass1_omits": None if pass1_omits is None else [pass1_omits],
252+
"pass1_extras": None if pass1_extras is None else [pass1_extras],
253+
}
254+
if two_pass
255+
else {}
256+
)
257+
258+
(ffmpegprocess.run_two_pass if two_pass else ffmpegprocess.run)(
233259
ffmpeg_args,
234260
input=data,
235261
stdout=stdout,
236262
progress=progress,
237263
overwrite=overwrite,
264+
**kwargs,
238265
capture_log=False if show_log else None,
239266
)
240267

tests/test_transcode.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,42 @@ def test_transcode():
2323
pass
2424
transcode(url, out_url, overwrite=True, show_log=True, progress=progress)
2525

26-
# with open(path.join(tmpdirname, "progress.txt")) as f:
27-
# print(f.read())
26+
27+
def test_transcode_2pass():
28+
url = "tests/assets/testmulti-1m.mp4"
29+
30+
with tempfile.TemporaryDirectory() as tmpdirname:
31+
out_url = path.join(tmpdirname, path.basename(url))
32+
transcode(
33+
url,
34+
out_url,
35+
show_log=True,
36+
two_pass=True,
37+
t=1,
38+
**{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"}
39+
)
40+
41+
transcode(
42+
url,
43+
out_url,
44+
show_log=True,
45+
two_pass=True,
46+
pass1_omits=["c:a", "b:a"],
47+
pass1_extras={"an": None},
48+
overwrite=True,
49+
t=1,
50+
**{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"}
51+
)
2852

2953

3054
def test_transcode_vf():
3155
url = "tests/assets/testmulti-1m.mp4"
3256
with tempfile.TemporaryDirectory() as tmpdirname:
3357
# print(probe.audio_streams_basic(url))
3458
out_url = path.join(tmpdirname, path.basename(url))
35-
transcode(url, out_url, t='0.1', vf="scale=in_w:in_h*9/10", show_log=True)
59+
transcode(url, out_url, t="0.1", vf="scale=in_w:in_h*9/10", show_log=True)
3660
assert path.isfile(out_url)
3761

3862

3963
if __name__ == "__main__":
40-
test_transcode()
64+
test_transcode_2pass()

0 commit comments

Comments
 (0)