Skip to content

Commit 2f3331c

Browse files
author
Tao Peng
authored
feat: add option to allow capture stderr from ffmpeg/ffprobe (#565)
* feat: add option to allow capture stderr from ffmpeg/ffprobe * add tests * fix tests * add comments * fix decoding error * fix * fix
1 parent 2bb2ef6 commit 2f3331c

File tree

2 files changed

+151
-29
lines changed

2 files changed

+151
-29
lines changed

mapillary_tools/ffmpeg.py

Lines changed: 76 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -34,52 +34,117 @@ class Stream(TypedDict):
3434
width: int
3535

3636

37-
class ProbeFormat(TypedDict):
38-
filename: str
39-
duration: str
40-
41-
4237
class ProbeOutput(TypedDict):
4338
streams: T.List[Stream]
44-
format: T.Dict
4539

4640

4741
class FFmpegNotFoundError(Exception):
4842
pass
4943

5044

45+
_MAX_STDERR_LENGTH = 2048
46+
47+
48+
def _truncate_begin(s: str) -> str:
49+
if _MAX_STDERR_LENGTH < len(s):
50+
return "..." + s[-_MAX_STDERR_LENGTH:]
51+
else:
52+
return s
53+
54+
55+
def _truncate_end(s: str) -> str:
56+
if _MAX_STDERR_LENGTH < len(s):
57+
return s[:_MAX_STDERR_LENGTH] + "..."
58+
else:
59+
return s
60+
61+
62+
class FFmpegCalledProcessError(Exception):
63+
def __init__(self, ex: subprocess.CalledProcessError):
64+
self.inner_ex = ex
65+
66+
def __str__(self) -> str:
67+
msg = str(self.inner_ex)
68+
if self.inner_ex.stderr is not None:
69+
try:
70+
stderr = self.inner_ex.stderr.decode("utf-8")
71+
except UnicodeDecodeError:
72+
stderr = str(self.inner_ex.stderr)
73+
msg += f"\nSTDERR: {_truncate_begin(stderr)}"
74+
return msg
75+
76+
5177
class FFMPEG:
5278
def __init__(
53-
self, ffmpeg_path: str = "ffmpeg", ffprobe_path: str = "ffprobe"
79+
self,
80+
ffmpeg_path: str = "ffmpeg",
81+
ffprobe_path: str = "ffprobe",
82+
stderr: T.Optional[int] = None,
5483
) -> None:
84+
"""
85+
ffmpeg_path: path to ffmpeg binary
86+
ffprobe_path: path to ffprobe binary
87+
stderr: param passed to subprocess.run to control whether to capture stderr
88+
"""
5589
self.ffmpeg_path = ffmpeg_path
5690
self.ffprobe_path = ffprobe_path
91+
self.stderr = stderr
5792

5893
def _run_ffprobe_json(self, cmd: T.List[str]) -> T.Dict:
5994
full_cmd = [self.ffprobe_path, "-print_format", "json", *cmd]
6095
LOG.info(f"Extracting video information: {' '.join(full_cmd)}")
6196
try:
62-
output = subprocess.check_output(full_cmd)
97+
completed = subprocess.run(
98+
full_cmd,
99+
check=True,
100+
stdout=subprocess.PIPE,
101+
stderr=self.stderr,
102+
)
63103
except FileNotFoundError:
64104
raise FFmpegNotFoundError(
65105
f'The ffprobe command "{self.ffprobe_path}" not found'
66106
)
107+
except subprocess.CalledProcessError as ex:
108+
raise FFmpegCalledProcessError(ex) from ex
109+
67110
try:
68-
return json.loads(output)
111+
stdout = completed.stdout.decode("utf-8")
112+
except UnicodeDecodeError:
113+
raise RuntimeError(
114+
f"Error decoding ffprobe output as unicode: {_truncate_end(str(completed.stdout))}"
115+
)
116+
117+
try:
118+
output = json.loads(stdout)
69119
except json.JSONDecodeError:
70120
raise RuntimeError(
71-
f"Error JSON decoding ffprobe output: {output.decode('utf-8')}"
121+
f"Error JSON decoding ffprobe output: {_truncate_end(stdout)}"
122+
)
123+
124+
# This check is for macOS:
125+
# ffprobe -hide_banner -print_format json not_exists
126+
# you will get exit code == 0 with the following stdout and stderr:
127+
# {
128+
# }
129+
# not_exists: No such file or directory
130+
if not output:
131+
raise RuntimeError(
132+
f"Empty JSON ffprobe output with STDERR: {_truncate_begin(str(completed.stderr))}"
72133
)
73134

135+
return output
136+
74137
def _run_ffmpeg(self, cmd: T.List[str]) -> None:
75138
full_cmd = [self.ffmpeg_path, *cmd]
76139
LOG.info(f"Extracting frames: {' '.join(full_cmd)}")
77140
try:
78-
subprocess.check_call(full_cmd)
141+
subprocess.run(full_cmd, check=True, stderr=self.stderr)
79142
except FileNotFoundError:
80143
raise FFmpegNotFoundError(
81144
f'The ffmpeg command "{self.ffmpeg_path}" not found'
82145
)
146+
except subprocess.CalledProcessError as ex:
147+
raise FFmpegCalledProcessError(ex) from ex
83148

84149
def probe_format_and_streams(self, video_path: Path) -> ProbeOutput:
85150
cmd = [
@@ -90,24 +155,6 @@ def probe_format_and_streams(self, video_path: Path) -> ProbeOutput:
90155
]
91156
return T.cast(ProbeOutput, self._run_ffprobe_json(cmd))
92157

93-
def extract_stream(self, source: Path, dest: Path, stream_id: int) -> None:
94-
cmd = [
95-
"-hide_banner",
96-
"-i",
97-
str(source),
98-
"-y", # overwrite - potentially dangerous
99-
"-nostats",
100-
"-codec",
101-
"copy",
102-
"-map",
103-
f"0:{stream_id}",
104-
"-f",
105-
"rawvideo",
106-
str(dest),
107-
]
108-
109-
self._run_ffmpeg(cmd)
110-
111158
def extract_frames(
112159
self,
113160
video_path: Path,

tests/unit/test_ffmpeg.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import os
2+
import subprocess
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from mapillary_tools import ffmpeg
8+
9+
10+
def ffmpeg_installed():
11+
ffmpeg_path = os.getenv("MAPILLARY_TOOLS_FFMPEG_PATH", "ffmpeg")
12+
ffprobe_path = os.getenv("MAPILLARY_TOOLS_FFPROBE_PATH", "ffprobe")
13+
try:
14+
subprocess.run([ffmpeg_path, "-version"])
15+
# In Windows, ffmpeg is installed but ffprobe is not?
16+
subprocess.run([ffprobe_path, "-version"])
17+
except FileNotFoundError:
18+
return False
19+
return True
20+
21+
22+
is_ffmpeg_installed = ffmpeg_installed()
23+
24+
25+
def test_ffmpeg_not_exists():
26+
if not is_ffmpeg_installed:
27+
pytest.skip("ffmpeg not installed")
28+
29+
ff = ffmpeg.FFMPEG()
30+
try:
31+
ff.extract_frames(Path("not_exist_a"), Path("not_exist_b"), sample_interval=2)
32+
except ffmpeg.FFmpegCalledProcessError as ex:
33+
assert "STDERR:" not in str(ex)
34+
else:
35+
assert False, "FFmpegCalledProcessError not raised"
36+
37+
ff = ffmpeg.FFMPEG(stderr=subprocess.PIPE)
38+
try:
39+
ff.extract_frames(Path("not_exist_a"), Path("not_exist_b"), sample_interval=2)
40+
except ffmpeg.FFmpegCalledProcessError as ex:
41+
assert "STDERR:" in str(ex)
42+
else:
43+
assert False, "FFmpegCalledProcessError not raised"
44+
45+
46+
def test_ffprobe_not_exists():
47+
if not is_ffmpeg_installed:
48+
pytest.skip("ffmpeg not installed")
49+
50+
ff = ffmpeg.FFMPEG()
51+
try:
52+
x = ff.probe_format_and_streams(Path("not_exist_a"))
53+
except ffmpeg.FFmpegCalledProcessError as ex:
54+
# exc from linux
55+
assert "STDERR:" not in str(ex)
56+
except RuntimeError as ex:
57+
# exc from macos
58+
assert "Empty JSON ffprobe output with STDERR: None" == str(ex)
59+
else:
60+
assert False, "RuntimeError not raised"
61+
62+
ff = ffmpeg.FFMPEG(stderr=subprocess.PIPE)
63+
try:
64+
x = ff.probe_format_and_streams(Path("not_exist_a"))
65+
except ffmpeg.FFmpegCalledProcessError as ex:
66+
# exc from linux
67+
assert "STDERR:" in str(ex)
68+
except RuntimeError as ex:
69+
# exc from macos
70+
assert (
71+
"Empty JSON ffprobe output with STDERR: b'not_exist_a: No such file or directory"
72+
in str(ex)
73+
)
74+
else:
75+
assert False, "RuntimeError not raised"

0 commit comments

Comments
 (0)