mcap-codec-support 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcap_codec_support/__init__.py +1 -0
- mcap_codec_support/_messages.py +56 -0
- mcap_codec_support/_protocols.py +94 -0
- mcap_codec_support/_schemas.py +17 -0
- mcap_codec_support/pointcloud/__init__.py +45 -0
- mcap_codec_support/pointcloud/compression.py +340 -0
- mcap_codec_support/pointcloud/factories.py +473 -0
- mcap_codec_support/pointcloud/schemas.py +111 -0
- mcap_codec_support/py.typed +0 -0
- mcap_codec_support/video/__init__.py +55 -0
- mcap_codec_support/video/common.py +283 -0
- mcap_codec_support/video/compression.py +269 -0
- mcap_codec_support/video/factories.py +123 -0
- mcap_codec_support/video/ffmpeg.py +944 -0
- mcap_codec_support/video/file_writer.py +411 -0
- mcap_codec_support/video/pyav.py +413 -0
- mcap_codec_support/video/schemas.py +52 -0
- mcap_codec_support-0.2.0.dist-info/METADATA +53 -0
- mcap_codec_support-0.2.0.dist-info/RECORD +20 -0
- mcap_codec_support-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
"""FFmpeg subprocess-based video compression and decompression backend.
|
|
2
|
+
|
|
3
|
+
All ``ffmpeg`` / ``ffprobe`` subprocess usage is confined to this module.
|
|
4
|
+
**No PyAV dependency** — only requires ``ffmpeg`` on PATH.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from queue import Empty, Queue
|
|
16
|
+
|
|
17
|
+
from mcap_codec_support.video.common import (
|
|
18
|
+
DecompressedFrame,
|
|
19
|
+
EncoderConfig,
|
|
20
|
+
VideoEncoderError,
|
|
21
|
+
build_encoder_options,
|
|
22
|
+
)
|
|
23
|
+
from mcap_codec_support.video.common import (
|
|
24
|
+
resolve_encoder as _resolve_encoder,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# ffmpeg discovery
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_ffmpeg() -> str | None:
|
|
33
|
+
"""Return the path to ``ffmpeg`` if it is on PATH, else None."""
|
|
34
|
+
return shutil.which("ffmpeg")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_encoder_cli(encoder_name: str) -> bool:
|
|
38
|
+
"""Check whether the system ``ffmpeg`` supports *encoder_name*."""
|
|
39
|
+
ffmpeg = find_ffmpeg()
|
|
40
|
+
if not ffmpeg:
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run( # noqa: S603
|
|
44
|
+
[ffmpeg, "-hide_banner", "-encoders"],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
timeout=5,
|
|
48
|
+
check=False,
|
|
49
|
+
)
|
|
50
|
+
for line in result.stdout.splitlines():
|
|
51
|
+
parts = line.split()
|
|
52
|
+
if len(parts) >= 2 and parts[1] == encoder_name:
|
|
53
|
+
return True
|
|
54
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
55
|
+
pass
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_encoder(codec: str) -> str:
|
|
60
|
+
"""Pick the best available encoder for *codec* using ffmpeg CLI to probe."""
|
|
61
|
+
if not find_ffmpeg():
|
|
62
|
+
raise VideoEncoderError("ffmpeg not found on PATH")
|
|
63
|
+
try:
|
|
64
|
+
return _resolve_encoder(codec, test_fn=check_encoder_cli)
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
raise VideoEncoderError(str(exc)) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Mapping from ROS image encoding to ffmpeg pixel format.
|
|
70
|
+
ROS_ENCODING_TO_PIX_FMT: dict[str, str] = {
|
|
71
|
+
"rgb": "rgb24",
|
|
72
|
+
"rgb8": "rgb24",
|
|
73
|
+
"bgr": "bgr24",
|
|
74
|
+
"bgr8": "bgr24",
|
|
75
|
+
"mono": "gray",
|
|
76
|
+
"mono8": "gray",
|
|
77
|
+
"8uc1": "gray",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Annex B access-unit splitter
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
_START_CODE_4 = b"\x00\x00\x00\x01"
|
|
86
|
+
_H264_VCL_TYPES = frozenset({1, 2, 3, 4, 5})
|
|
87
|
+
_H265_MAX_VCL_TYPE = 31
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AnnexBParser:
|
|
91
|
+
"""Split an Annex B byte stream into per-access-unit chunks."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, codec: str) -> None:
|
|
94
|
+
self._is_h265 = "265" in codec or "hevc" in codec
|
|
95
|
+
self._buf = bytearray()
|
|
96
|
+
self._current_au = bytearray()
|
|
97
|
+
self._current_has_vcl = False
|
|
98
|
+
|
|
99
|
+
def _is_vcl(self, nal_header: int) -> bool:
|
|
100
|
+
if self._is_h265:
|
|
101
|
+
nal_type = (nal_header >> 1) & 0x3F
|
|
102
|
+
return nal_type <= _H265_MAX_VCL_TYPE
|
|
103
|
+
nal_type = nal_header & 0x1F
|
|
104
|
+
return nal_type in _H264_VCL_TYPES
|
|
105
|
+
|
|
106
|
+
def feed(self, data: bytes) -> list[bytes]:
|
|
107
|
+
self._buf.extend(data)
|
|
108
|
+
result: list[bytes] = []
|
|
109
|
+
|
|
110
|
+
while True:
|
|
111
|
+
first = self._buf.find(_START_CODE_4)
|
|
112
|
+
if first == -1:
|
|
113
|
+
break
|
|
114
|
+
second = self._buf.find(_START_CODE_4, first + 4)
|
|
115
|
+
if second == -1 or second + 4 >= len(self._buf):
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
next_nal_header = self._buf[second + 4]
|
|
119
|
+
next_is_vcl = self._is_vcl(next_nal_header)
|
|
120
|
+
|
|
121
|
+
if first + 4 < len(self._buf):
|
|
122
|
+
cur_nal_header = self._buf[first + 4]
|
|
123
|
+
if self._is_vcl(cur_nal_header) and not self._current_has_vcl:
|
|
124
|
+
self._current_has_vcl = True
|
|
125
|
+
|
|
126
|
+
if next_is_vcl and self._current_has_vcl:
|
|
127
|
+
self._current_au.extend(self._buf[:second])
|
|
128
|
+
result.append(bytes(self._current_au))
|
|
129
|
+
self._current_au = bytearray()
|
|
130
|
+
self._current_has_vcl = False
|
|
131
|
+
self._buf = self._buf[second:]
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
if next_is_vcl:
|
|
135
|
+
self._current_has_vcl = True
|
|
136
|
+
|
|
137
|
+
self._current_au.extend(self._buf[:second])
|
|
138
|
+
self._buf = self._buf[second:]
|
|
139
|
+
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
def flush(self) -> bytes | None:
|
|
143
|
+
"""Return any remaining data as a single final access unit."""
|
|
144
|
+
items = self.flush_list()
|
|
145
|
+
if not items:
|
|
146
|
+
return None
|
|
147
|
+
return b"".join(items)
|
|
148
|
+
|
|
149
|
+
def flush_list(self) -> list[bytes]:
|
|
150
|
+
"""Return remaining data split into individual access units."""
|
|
151
|
+
# Combine all buffered data.
|
|
152
|
+
self._current_au.extend(self._buf)
|
|
153
|
+
self._buf.clear()
|
|
154
|
+
remaining = bytes(self._current_au)
|
|
155
|
+
self._current_au.clear()
|
|
156
|
+
self._current_has_vcl = False
|
|
157
|
+
|
|
158
|
+
if not remaining:
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
# Split by VCL NAL boundaries — each VCL NAL starts a new AU.
|
|
162
|
+
# Find all start code positions.
|
|
163
|
+
positions: list[int] = []
|
|
164
|
+
pos = 0
|
|
165
|
+
while True:
|
|
166
|
+
idx = remaining.find(_START_CODE_4, pos)
|
|
167
|
+
if idx == -1:
|
|
168
|
+
break
|
|
169
|
+
positions.append(idx)
|
|
170
|
+
pos = idx + 4
|
|
171
|
+
|
|
172
|
+
if not positions:
|
|
173
|
+
return [remaining]
|
|
174
|
+
|
|
175
|
+
# Find AU boundaries: a new AU starts at each VCL NAL that follows
|
|
176
|
+
# a previous VCL NAL (i.e., the second and subsequent VCL NALs).
|
|
177
|
+
au_starts = [positions[0]]
|
|
178
|
+
seen_vcl = False
|
|
179
|
+
for p in positions:
|
|
180
|
+
if p + 4 >= len(remaining):
|
|
181
|
+
continue
|
|
182
|
+
nal_header = remaining[p + 4]
|
|
183
|
+
is_vcl = self._is_vcl(nal_header)
|
|
184
|
+
if is_vcl and seen_vcl:
|
|
185
|
+
au_starts.append(p)
|
|
186
|
+
if is_vcl:
|
|
187
|
+
seen_vcl = True
|
|
188
|
+
|
|
189
|
+
# Split into AUs.
|
|
190
|
+
aus: list[bytes] = []
|
|
191
|
+
for i, start in enumerate(au_starts):
|
|
192
|
+
end = au_starts[i + 1] if i + 1 < len(au_starts) else len(remaining)
|
|
193
|
+
aus.append(remaining[start:end])
|
|
194
|
+
return aus
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# Codec helpers
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
_CODEC_TO_FORMAT: dict[str, str] = {
|
|
202
|
+
"h264": "h264",
|
|
203
|
+
"h265": "hevc",
|
|
204
|
+
"hevc": "hevc",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _codec_family(codec_name: str) -> str:
|
|
209
|
+
lower = codec_name.lower()
|
|
210
|
+
if "264" in lower:
|
|
211
|
+
return "h264"
|
|
212
|
+
if "265" in lower or "hevc" in lower:
|
|
213
|
+
return "h265"
|
|
214
|
+
return "h264"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _build_output_args(
|
|
218
|
+
codec_fam: str, codec_name: str, gop_size: int, options: dict[str, str], bit_rate: int | None
|
|
219
|
+
) -> list[str]:
|
|
220
|
+
"""Build the shared encoder output arguments."""
|
|
221
|
+
cmd: list[str] = [
|
|
222
|
+
"-vsync",
|
|
223
|
+
"0",
|
|
224
|
+
"-c:v",
|
|
225
|
+
codec_name,
|
|
226
|
+
"-g",
|
|
227
|
+
str(gop_size),
|
|
228
|
+
"-bf",
|
|
229
|
+
"0",
|
|
230
|
+
"-pix_fmt",
|
|
231
|
+
"yuv420p",
|
|
232
|
+
"-fflags",
|
|
233
|
+
"+flush_packets",
|
|
234
|
+
]
|
|
235
|
+
if bit_rate is not None:
|
|
236
|
+
cmd.extend(["-b:v", str(bit_rate)])
|
|
237
|
+
for key, value in options.items():
|
|
238
|
+
cmd.extend([f"-{key}", value])
|
|
239
|
+
|
|
240
|
+
output_fmt = _CODEC_TO_FORMAT.get(codec_fam, "h264")
|
|
241
|
+
cmd.extend(["-f", output_fmt, "pipe:1"])
|
|
242
|
+
return cmd
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _require_ffmpeg() -> str:
|
|
246
|
+
ffmpeg = find_ffmpeg()
|
|
247
|
+
if not ffmpeg:
|
|
248
|
+
raise VideoEncoderError("ffmpeg not found on PATH")
|
|
249
|
+
return ffmpeg
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# FFmpegVideoEncoder (unified: rawvideo or image2pipe → H.264/H.265)
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
# JPEG magic bytes.
|
|
257
|
+
_JPEG_MAGIC = b"\xff\xd8"
|
|
258
|
+
# PNG magic bytes.
|
|
259
|
+
_PNG_MAGIC = b"\x89PNG"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def probe_image_dimensions(data: bytes) -> tuple[int, int]:
|
|
263
|
+
"""Extract (width, height) from a JPEG or PNG image header.
|
|
264
|
+
|
|
265
|
+
Falls back to ffprobe if the header cannot be parsed.
|
|
266
|
+
"""
|
|
267
|
+
if data[:2] == _JPEG_MAGIC:
|
|
268
|
+
dims = parse_jpeg_dimensions(data)
|
|
269
|
+
if dims:
|
|
270
|
+
return dims
|
|
271
|
+
elif data[:4] == _PNG_MAGIC and len(data) >= 24:
|
|
272
|
+
width = int.from_bytes(data[16:20], "big")
|
|
273
|
+
height = int.from_bytes(data[20:24], "big")
|
|
274
|
+
return width, height
|
|
275
|
+
|
|
276
|
+
# Fallback: use ffprobe.
|
|
277
|
+
return _ffprobe_image_dimensions(data)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def parse_jpeg_dimensions(data: bytes) -> tuple[int, int] | None:
|
|
281
|
+
"""Parse JPEG SOF marker for dimensions. Returns None on failure."""
|
|
282
|
+
i = 2
|
|
283
|
+
while i < len(data) - 9:
|
|
284
|
+
if data[i] != 0xFF:
|
|
285
|
+
return None
|
|
286
|
+
marker = data[i + 1]
|
|
287
|
+
# SOF0 (0xC0) or SOF2 (0xC2) — baseline or progressive.
|
|
288
|
+
if marker in (0xC0, 0xC2):
|
|
289
|
+
height = (data[i + 5] << 8) | data[i + 6]
|
|
290
|
+
width = (data[i + 7] << 8) | data[i + 8]
|
|
291
|
+
return width, height
|
|
292
|
+
if marker == 0xD9: # EOI
|
|
293
|
+
return None
|
|
294
|
+
# Skip over marker segment.
|
|
295
|
+
seg_len = (data[i + 2] << 8) | data[i + 3]
|
|
296
|
+
i += 2 + seg_len
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _ffprobe_image_dimensions(data: bytes) -> tuple[int, int]:
|
|
301
|
+
"""Probe image dimensions via ffprobe subprocess."""
|
|
302
|
+
ffprobe = shutil.which("ffprobe")
|
|
303
|
+
if not ffprobe:
|
|
304
|
+
raise VideoEncoderError("ffprobe not found (needed for image dimension probing)")
|
|
305
|
+
cmd = [
|
|
306
|
+
ffprobe,
|
|
307
|
+
"-hide_banner",
|
|
308
|
+
"-loglevel",
|
|
309
|
+
"error",
|
|
310
|
+
"-select_streams",
|
|
311
|
+
"v:0",
|
|
312
|
+
"-show_entries",
|
|
313
|
+
"stream=width,height",
|
|
314
|
+
"-of",
|
|
315
|
+
"csv=p=0",
|
|
316
|
+
"-i",
|
|
317
|
+
"pipe:0",
|
|
318
|
+
]
|
|
319
|
+
try:
|
|
320
|
+
result = subprocess.run( # noqa: S603
|
|
321
|
+
cmd, input=data, capture_output=True, text=True, timeout=10, check=False
|
|
322
|
+
)
|
|
323
|
+
parts = result.stdout.strip().split(",")
|
|
324
|
+
if len(parts) == 2:
|
|
325
|
+
return int(parts[0]), int(parts[1])
|
|
326
|
+
except (subprocess.TimeoutExpired, OSError, ValueError):
|
|
327
|
+
pass
|
|
328
|
+
raise VideoEncoderError("Cannot determine image dimensions")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class FFmpegVideoEncoder:
|
|
332
|
+
"""Encode frames to H.264/H.265 via a single ffmpeg process.
|
|
333
|
+
|
|
334
|
+
When *input_pix_fmt* is ``None`` (the default), the encoder accepts
|
|
335
|
+
JPEG/PNG bytes via ``image2pipe``. When set (e.g. ``"rgb24"``), it
|
|
336
|
+
accepts raw pixel data of that format via ``rawvideo``.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
def __init__(
|
|
340
|
+
self,
|
|
341
|
+
width: int,
|
|
342
|
+
height: int,
|
|
343
|
+
codec_name: str,
|
|
344
|
+
quality: int = 28,
|
|
345
|
+
target_fps: float = 30.0,
|
|
346
|
+
gop_size: int = 30,
|
|
347
|
+
*,
|
|
348
|
+
preset: str | None = None,
|
|
349
|
+
input_pix_fmt: str | None = None,
|
|
350
|
+
scale: tuple[int, int] | None = None,
|
|
351
|
+
) -> None:
|
|
352
|
+
ffmpeg = _require_ffmpeg()
|
|
353
|
+
codec_fam = _codec_family(codec_name)
|
|
354
|
+
fps_int = max(round(target_fps), 1)
|
|
355
|
+
options, bit_rate = build_encoder_options(codec_name, quality, width, height, preset=preset)
|
|
356
|
+
|
|
357
|
+
if input_pix_fmt is not None:
|
|
358
|
+
# Raw pixel data (rgb24, bgr24, gray, etc.).
|
|
359
|
+
cmd: list[str] = [
|
|
360
|
+
ffmpeg,
|
|
361
|
+
"-hide_banner",
|
|
362
|
+
"-loglevel",
|
|
363
|
+
"error",
|
|
364
|
+
"-f",
|
|
365
|
+
"rawvideo",
|
|
366
|
+
"-pix_fmt",
|
|
367
|
+
input_pix_fmt,
|
|
368
|
+
"-s",
|
|
369
|
+
f"{width}x{height}",
|
|
370
|
+
"-r",
|
|
371
|
+
str(fps_int),
|
|
372
|
+
"-i",
|
|
373
|
+
"pipe:0",
|
|
374
|
+
]
|
|
375
|
+
else:
|
|
376
|
+
# Compressed images (JPEG/PNG/etc.) — let ffmpeg detect the image codec.
|
|
377
|
+
cmd = [
|
|
378
|
+
ffmpeg,
|
|
379
|
+
"-hide_banner",
|
|
380
|
+
"-loglevel",
|
|
381
|
+
"error",
|
|
382
|
+
"-f",
|
|
383
|
+
"image2pipe",
|
|
384
|
+
"-r",
|
|
385
|
+
str(fps_int),
|
|
386
|
+
"-i",
|
|
387
|
+
"pipe:0",
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
if scale is not None:
|
|
391
|
+
sw, sh = scale
|
|
392
|
+
cmd.extend(["-vf", f"scale={sw}:{sh}"])
|
|
393
|
+
|
|
394
|
+
cmd.extend(_build_output_args(codec_fam, codec_name, gop_size, options, bit_rate))
|
|
395
|
+
|
|
396
|
+
self.config = EncoderConfig(width=width, height=height, codec_name=codec_name)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
self._process = subprocess.Popen( # noqa: S603
|
|
400
|
+
cmd,
|
|
401
|
+
stdin=subprocess.PIPE,
|
|
402
|
+
stdout=subprocess.PIPE,
|
|
403
|
+
stderr=subprocess.PIPE,
|
|
404
|
+
)
|
|
405
|
+
except OSError as exc:
|
|
406
|
+
raise VideoEncoderError(f"Failed to start ffmpeg: {exc}") from exc
|
|
407
|
+
|
|
408
|
+
self._parser = AnnexBParser(codec_fam)
|
|
409
|
+
self._output_queue: Queue[bytes | None] = Queue()
|
|
410
|
+
self._stderr_lines: list[str] = []
|
|
411
|
+
|
|
412
|
+
self._stdout_thread = threading.Thread(target=self._read_stdout, daemon=True)
|
|
413
|
+
self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
|
414
|
+
self._stdout_thread.start()
|
|
415
|
+
self._stderr_thread.start()
|
|
416
|
+
|
|
417
|
+
self._is_image_pipe = input_pix_fmt is None
|
|
418
|
+
self._last_frame: bytes | None = None
|
|
419
|
+
self._frames_fed = 0
|
|
420
|
+
self._frames_returned = 0
|
|
421
|
+
|
|
422
|
+
def _read_stdout(self) -> None:
|
|
423
|
+
if self._process.stdout is None:
|
|
424
|
+
return
|
|
425
|
+
fd = self._process.stdout.fileno()
|
|
426
|
+
os.set_blocking(fd, False)
|
|
427
|
+
try:
|
|
428
|
+
while True:
|
|
429
|
+
try:
|
|
430
|
+
chunk = os.read(fd, 65536)
|
|
431
|
+
if not chunk:
|
|
432
|
+
break
|
|
433
|
+
for au in self._parser.feed(chunk):
|
|
434
|
+
self._output_queue.put(au)
|
|
435
|
+
except BlockingIOError:
|
|
436
|
+
time.sleep(0.005)
|
|
437
|
+
except OSError:
|
|
438
|
+
break
|
|
439
|
+
for au in self._parser.flush_list():
|
|
440
|
+
self._output_queue.put(au)
|
|
441
|
+
finally:
|
|
442
|
+
self._output_queue.put(None)
|
|
443
|
+
|
|
444
|
+
def _read_stderr(self) -> None:
|
|
445
|
+
if self._process.stderr is None:
|
|
446
|
+
return
|
|
447
|
+
for raw_line in self._process.stderr:
|
|
448
|
+
text = raw_line.decode(errors="replace").rstrip()
|
|
449
|
+
if text:
|
|
450
|
+
self._stderr_lines.append(text)
|
|
451
|
+
|
|
452
|
+
def _encode_raw(self, frame: bytes) -> bytes | None:
|
|
453
|
+
"""Write *frame* bytes to ffmpeg stdin and return one access unit (or None)."""
|
|
454
|
+
if self._process.stdin is None:
|
|
455
|
+
raise VideoEncoderError("ffmpeg stdin is not available")
|
|
456
|
+
try:
|
|
457
|
+
self._process.stdin.write(frame)
|
|
458
|
+
self._process.stdin.flush()
|
|
459
|
+
except BrokenPipeError as exc:
|
|
460
|
+
stderr_tail = "\n".join(self._stderr_lines[-5:])
|
|
461
|
+
raise VideoEncoderError(f"ffmpeg process died unexpectedly:\n{stderr_tail}") from exc
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
au = self._output_queue.get(timeout=0.2)
|
|
465
|
+
except Empty:
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
if au is None:
|
|
469
|
+
stderr_tail = "\n".join(self._stderr_lines[-5:])
|
|
470
|
+
raise VideoEncoderError(f"ffmpeg exited prematurely:\n{stderr_tail}")
|
|
471
|
+
return au
|
|
472
|
+
|
|
473
|
+
def encode(self, frame: bytes) -> bytes | None:
|
|
474
|
+
"""Write *frame* bytes to ffmpeg stdin and return one access unit (or None)."""
|
|
475
|
+
self._last_frame = frame
|
|
476
|
+
self._frames_fed += 1
|
|
477
|
+
result = self._encode_raw(frame)
|
|
478
|
+
if result is not None:
|
|
479
|
+
self._frames_returned += 1
|
|
480
|
+
return result
|
|
481
|
+
|
|
482
|
+
def _flush_packets_raw(self) -> list[bytes]:
|
|
483
|
+
"""Close the encoder and return remaining buffered access units as a list."""
|
|
484
|
+
if self._process.stdin and not self._process.stdin.closed:
|
|
485
|
+
self._process.stdin.close()
|
|
486
|
+
|
|
487
|
+
self._stdout_thread.join(timeout=10)
|
|
488
|
+
self._stderr_thread.join(timeout=5)
|
|
489
|
+
self._process.wait(timeout=10)
|
|
490
|
+
|
|
491
|
+
packets: list[bytes] = []
|
|
492
|
+
while True:
|
|
493
|
+
try:
|
|
494
|
+
item = self._output_queue.get_nowait()
|
|
495
|
+
except Empty:
|
|
496
|
+
break
|
|
497
|
+
if item is None:
|
|
498
|
+
break
|
|
499
|
+
packets.append(item)
|
|
500
|
+
|
|
501
|
+
if self._process.returncode and self._process.returncode != 0:
|
|
502
|
+
stderr_tail = "\n".join(self._stderr_lines[-5:])
|
|
503
|
+
raise VideoEncoderError(
|
|
504
|
+
f"ffmpeg exited with code {self._process.returncode}:\n{stderr_tail}"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
return packets
|
|
508
|
+
|
|
509
|
+
def flush_packets(self) -> list[bytes]:
|
|
510
|
+
"""Flush encoder, sending padding frames to prevent frame loss.
|
|
511
|
+
|
|
512
|
+
ffmpeg drops the last N frames when stdin closes (both image2pipe
|
|
513
|
+
and rawvideo). We compensate by sending extra copies of the last
|
|
514
|
+
frame, then trimming the excess AUs from the output.
|
|
515
|
+
"""
|
|
516
|
+
if self._last_frame is not None:
|
|
517
|
+
try:
|
|
518
|
+
assert self._process.stdin is not None
|
|
519
|
+
for _ in range(2):
|
|
520
|
+
self._process.stdin.write(self._last_frame)
|
|
521
|
+
self._process.stdin.flush()
|
|
522
|
+
except (BrokenPipeError, AssertionError):
|
|
523
|
+
pass
|
|
524
|
+
packets = self._flush_packets_raw()
|
|
525
|
+
# Keep only enough packets to reach the real frame count.
|
|
526
|
+
needed = self._frames_fed - self._frames_returned
|
|
527
|
+
return packets[:needed]
|
|
528
|
+
|
|
529
|
+
def __del__(self) -> None:
|
|
530
|
+
try:
|
|
531
|
+
if self._process.poll() is None:
|
|
532
|
+
self._process.kill()
|
|
533
|
+
self._process.wait(timeout=2)
|
|
534
|
+
except Exception: # noqa: BLE001, S110
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ---------------------------------------------------------------------------
|
|
539
|
+
# FFmpegVideoDecompressor (H.264/H.265 → Image)
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
_JPEG_SOI = b"\xff\xd8"
|
|
543
|
+
_JPEG_EOI = b"\xff\xd9"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class FFmpegVideoDecompressor:
|
|
547
|
+
"""Decompresses H.264/H.265 video to JPEG or raw RGB using ffmpeg subprocess.
|
|
548
|
+
|
|
549
|
+
Implements ``VideoDecompressorProtocol``. **No PyAV dependency.**
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
def __init__(
|
|
553
|
+
self,
|
|
554
|
+
video_format: str = "compressed",
|
|
555
|
+
jpeg_quality: int = 90,
|
|
556
|
+
) -> None:
|
|
557
|
+
self._video_format = video_format
|
|
558
|
+
self._jpeg_quality = jpeg_quality
|
|
559
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
560
|
+
self._output_queue: Queue[DecompressedFrame | None] = Queue()
|
|
561
|
+
self._stderr_lines: list[str] = []
|
|
562
|
+
self._stdout_thread: threading.Thread | None = None
|
|
563
|
+
self._stderr_thread: threading.Thread | None = None
|
|
564
|
+
# For raw mode: need dimensions to know frame size
|
|
565
|
+
self._width: int | None = None
|
|
566
|
+
self._height: int | None = None
|
|
567
|
+
self._probe_buffer = bytearray()
|
|
568
|
+
|
|
569
|
+
def _start_process(self, codec: str) -> None:
|
|
570
|
+
ffmpeg = find_ffmpeg()
|
|
571
|
+
if not ffmpeg:
|
|
572
|
+
raise VideoEncoderError("ffmpeg not found on PATH")
|
|
573
|
+
|
|
574
|
+
input_format = _CODEC_TO_FORMAT.get(codec, "h264")
|
|
575
|
+
|
|
576
|
+
cmd: list[str] = [
|
|
577
|
+
ffmpeg,
|
|
578
|
+
"-hide_banner",
|
|
579
|
+
"-loglevel",
|
|
580
|
+
"error",
|
|
581
|
+
"-f",
|
|
582
|
+
input_format,
|
|
583
|
+
"-i",
|
|
584
|
+
"pipe:0",
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
if self._video_format == "compressed":
|
|
588
|
+
# Output JPEG stream — one JPEG per frame.
|
|
589
|
+
q = max(1, 31 - self._jpeg_quality * 31 // 100)
|
|
590
|
+
cmd.extend(
|
|
591
|
+
[
|
|
592
|
+
"-c:v",
|
|
593
|
+
"mjpeg",
|
|
594
|
+
"-q:v",
|
|
595
|
+
str(q),
|
|
596
|
+
"-f",
|
|
597
|
+
"image2pipe",
|
|
598
|
+
"pipe:1",
|
|
599
|
+
]
|
|
600
|
+
)
|
|
601
|
+
else:
|
|
602
|
+
# Output raw RGB24 frames.
|
|
603
|
+
cmd.extend(
|
|
604
|
+
[
|
|
605
|
+
"-f",
|
|
606
|
+
"rawvideo",
|
|
607
|
+
"-pix_fmt",
|
|
608
|
+
"rgb24",
|
|
609
|
+
"pipe:1",
|
|
610
|
+
]
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
self._process = subprocess.Popen( # noqa: S603
|
|
615
|
+
cmd,
|
|
616
|
+
stdin=subprocess.PIPE,
|
|
617
|
+
stdout=subprocess.PIPE,
|
|
618
|
+
stderr=subprocess.PIPE,
|
|
619
|
+
)
|
|
620
|
+
except OSError as exc:
|
|
621
|
+
raise VideoEncoderError(f"Failed to start ffmpeg decoder: {exc}") from exc
|
|
622
|
+
|
|
623
|
+
self._stdout_thread = threading.Thread(target=self._read_stdout, daemon=True)
|
|
624
|
+
self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
|
625
|
+
self._stdout_thread.start()
|
|
626
|
+
self._stderr_thread.start()
|
|
627
|
+
|
|
628
|
+
def _detect_dimensions(self, data: bytes, codec: str) -> tuple[int, int]:
|
|
629
|
+
"""Detect video dimensions via ffprobe."""
|
|
630
|
+
ffprobe = shutil.which("ffprobe")
|
|
631
|
+
if not ffprobe:
|
|
632
|
+
raise VideoEncoderError("ffprobe not found on PATH")
|
|
633
|
+
|
|
634
|
+
input_format = _CODEC_TO_FORMAT.get(codec, "h264")
|
|
635
|
+
cmd = [
|
|
636
|
+
ffprobe,
|
|
637
|
+
"-hide_banner",
|
|
638
|
+
"-loglevel",
|
|
639
|
+
"error",
|
|
640
|
+
"-f",
|
|
641
|
+
input_format,
|
|
642
|
+
"-select_streams",
|
|
643
|
+
"v:0",
|
|
644
|
+
"-show_entries",
|
|
645
|
+
"stream=width,height",
|
|
646
|
+
"-of",
|
|
647
|
+
"csv=p=0",
|
|
648
|
+
"-i",
|
|
649
|
+
"pipe:0",
|
|
650
|
+
]
|
|
651
|
+
try:
|
|
652
|
+
result = subprocess.run( # noqa: S603
|
|
653
|
+
cmd, input=data, capture_output=True, timeout=10, check=False
|
|
654
|
+
)
|
|
655
|
+
parts = result.stdout.decode().strip().split(",")
|
|
656
|
+
if len(parts) == 2:
|
|
657
|
+
return int(parts[0]), int(parts[1])
|
|
658
|
+
except (subprocess.TimeoutExpired, OSError, ValueError):
|
|
659
|
+
pass
|
|
660
|
+
raise VideoEncoderError("Could not detect video dimensions from bitstream")
|
|
661
|
+
|
|
662
|
+
def _read_stdout(self) -> None:
|
|
663
|
+
if self._process is None or self._process.stdout is None:
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
if self._video_format == "compressed":
|
|
667
|
+
self._read_jpeg_stream()
|
|
668
|
+
else:
|
|
669
|
+
self._read_raw_stream()
|
|
670
|
+
|
|
671
|
+
self._output_queue.put(None) # sentinel
|
|
672
|
+
|
|
673
|
+
def _read_jpeg_stream(self) -> None:
|
|
674
|
+
"""Read a stream of concatenated JPEGs, split on SOI/EOI markers."""
|
|
675
|
+
if self._process is None or self._process.stdout is None:
|
|
676
|
+
return
|
|
677
|
+
buf = bytearray()
|
|
678
|
+
while True:
|
|
679
|
+
chunk = self._process.stdout.read(65536)
|
|
680
|
+
if not chunk:
|
|
681
|
+
break
|
|
682
|
+
buf.extend(chunk)
|
|
683
|
+
|
|
684
|
+
while True:
|
|
685
|
+
soi = buf.find(_JPEG_SOI)
|
|
686
|
+
if soi == -1:
|
|
687
|
+
break
|
|
688
|
+
eoi = buf.find(_JPEG_EOI, soi + 2)
|
|
689
|
+
if eoi == -1:
|
|
690
|
+
break
|
|
691
|
+
jpeg_data = bytes(buf[soi : eoi + 2])
|
|
692
|
+
del buf[: eoi + 2]
|
|
693
|
+
dims = parse_jpeg_dimensions(jpeg_data)
|
|
694
|
+
w, h = dims or (0, 0)
|
|
695
|
+
self._output_queue.put(
|
|
696
|
+
DecompressedFrame(data=jpeg_data, width=w, height=h, is_jpeg=True)
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
def _read_raw_stream(self) -> None:
|
|
700
|
+
"""Read raw RGB24 frames of known size."""
|
|
701
|
+
if self._process is None or self._process.stdout is None:
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
if self._width is None or self._height is None:
|
|
705
|
+
import logging # noqa: PLC0415
|
|
706
|
+
|
|
707
|
+
logging.getLogger(__name__).warning(
|
|
708
|
+
"FFmpegVideoDecompressor: dimensions unknown, discarding raw output"
|
|
709
|
+
)
|
|
710
|
+
self._process.stdout.read()
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
frame_size = self._width * self._height * 3
|
|
714
|
+
buf = bytearray()
|
|
715
|
+
while True:
|
|
716
|
+
chunk = self._process.stdout.read(65536)
|
|
717
|
+
if not chunk:
|
|
718
|
+
break
|
|
719
|
+
buf.extend(chunk)
|
|
720
|
+
while len(buf) >= frame_size:
|
|
721
|
+
frame_bytes = bytes(buf[:frame_size])
|
|
722
|
+
del buf[:frame_size]
|
|
723
|
+
self._output_queue.put(
|
|
724
|
+
DecompressedFrame(
|
|
725
|
+
data=frame_bytes,
|
|
726
|
+
width=self._width,
|
|
727
|
+
height=self._height,
|
|
728
|
+
is_jpeg=False,
|
|
729
|
+
)
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
def _read_stderr(self) -> None:
|
|
733
|
+
if self._process is None or self._process.stderr is None:
|
|
734
|
+
return
|
|
735
|
+
for line in self._process.stderr:
|
|
736
|
+
self._stderr_lines.append(line.decode(errors="replace").rstrip())
|
|
737
|
+
|
|
738
|
+
def decompress(self, video_data: bytes, codec: str) -> DecompressedFrame | None:
|
|
739
|
+
data_to_write = video_data
|
|
740
|
+
# Start process on first call.
|
|
741
|
+
if self._process is None:
|
|
742
|
+
# For raw mode, detect dimensions before starting.
|
|
743
|
+
if self._video_format != "compressed" and self._width is None:
|
|
744
|
+
self._probe_buffer.extend(video_data)
|
|
745
|
+
try:
|
|
746
|
+
self._width, self._height = self._detect_dimensions(
|
|
747
|
+
bytes(self._probe_buffer), codec
|
|
748
|
+
)
|
|
749
|
+
except VideoEncoderError:
|
|
750
|
+
return None
|
|
751
|
+
data_to_write = bytes(self._probe_buffer)
|
|
752
|
+
self._probe_buffer.clear()
|
|
753
|
+
self._start_process(codec)
|
|
754
|
+
|
|
755
|
+
if self._process is None or self._process.stdin is None:
|
|
756
|
+
raise VideoEncoderError("ffmpeg process not started")
|
|
757
|
+
|
|
758
|
+
self._process.stdin.write(data_to_write)
|
|
759
|
+
self._process.stdin.flush()
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
return self._output_queue.get(timeout=0.2)
|
|
763
|
+
except Empty:
|
|
764
|
+
return None
|
|
765
|
+
|
|
766
|
+
def flush(self) -> list[DecompressedFrame]:
|
|
767
|
+
if self._process is None:
|
|
768
|
+
return []
|
|
769
|
+
|
|
770
|
+
if self._process.stdin and not self._process.stdin.closed:
|
|
771
|
+
self._process.stdin.close()
|
|
772
|
+
|
|
773
|
+
frames: list[DecompressedFrame] = []
|
|
774
|
+
while True:
|
|
775
|
+
try:
|
|
776
|
+
frame = self._output_queue.get(timeout=5.0)
|
|
777
|
+
if frame is None:
|
|
778
|
+
break
|
|
779
|
+
frames.append(frame)
|
|
780
|
+
except Empty:
|
|
781
|
+
break
|
|
782
|
+
|
|
783
|
+
self._process.wait(timeout=5)
|
|
784
|
+
return frames
|
|
785
|
+
|
|
786
|
+
def __del__(self) -> None:
|
|
787
|
+
try:
|
|
788
|
+
if self._process and self._process.poll() is None:
|
|
789
|
+
self._process.kill()
|
|
790
|
+
self._process.wait(timeout=2)
|
|
791
|
+
except Exception: # noqa: BLE001, S110
|
|
792
|
+
pass
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# ---------------------------------------------------------------------------
|
|
796
|
+
# FFmpegMp4Encoder — raw RGB frames in via stdin, MP4 file out
|
|
797
|
+
# ---------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
class FFmpegMp4Encoder:
|
|
801
|
+
"""Encode a stream of raw RGB frames to an MP4 file via ffmpeg subprocess.
|
|
802
|
+
|
|
803
|
+
Sibling of :class:`FFmpegVideoEncoder`, which emits raw Annex B bitstream
|
|
804
|
+
to stdout for in-MCAP ``CompressedVideo`` messages. This class instead
|
|
805
|
+
asks ffmpeg to mux into MP4 and write to disk directly — what the
|
|
806
|
+
``video`` exporter wants when the user picks ``--mode ffmpeg-cli``.
|
|
807
|
+
"""
|
|
808
|
+
|
|
809
|
+
def __init__(
|
|
810
|
+
self,
|
|
811
|
+
output_path: os.PathLike[str] | str,
|
|
812
|
+
*,
|
|
813
|
+
width: int,
|
|
814
|
+
height: int,
|
|
815
|
+
codec_name: str,
|
|
816
|
+
quality: int = 28,
|
|
817
|
+
target_fps: float = 30.0,
|
|
818
|
+
gop_size: int = 60,
|
|
819
|
+
input_pix_fmt: str | None = "rgb24",
|
|
820
|
+
) -> None:
|
|
821
|
+
ffmpeg = _require_ffmpeg()
|
|
822
|
+
|
|
823
|
+
# Even dimensions required by yuv420p / common encoders.
|
|
824
|
+
width -= width % 2
|
|
825
|
+
height -= height % 2
|
|
826
|
+
if width < 2 or height < 2:
|
|
827
|
+
raise VideoEncoderError(f"Source frame too small ({width}x{height}) for video encoding")
|
|
828
|
+
|
|
829
|
+
if not check_encoder_cli(codec_name):
|
|
830
|
+
raise VideoEncoderError(
|
|
831
|
+
f"ffmpeg CLI does not support encoder {codec_name!r}. "
|
|
832
|
+
"Install a fuller ffmpeg build or pick a different --encoder backend."
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
options, bit_rate = build_encoder_options(codec_name, quality, width, height)
|
|
836
|
+
fps_int = max(round(target_fps), 1)
|
|
837
|
+
|
|
838
|
+
cmd: list[str] = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
|
|
839
|
+
if input_pix_fmt is None:
|
|
840
|
+
cmd.extend(["-f", "image2pipe", "-r", str(fps_int), "-i", "pipe:0"])
|
|
841
|
+
else:
|
|
842
|
+
cmd.extend(
|
|
843
|
+
[
|
|
844
|
+
"-f",
|
|
845
|
+
"rawvideo",
|
|
846
|
+
"-pix_fmt",
|
|
847
|
+
input_pix_fmt,
|
|
848
|
+
"-s",
|
|
849
|
+
f"{width}x{height}",
|
|
850
|
+
"-r",
|
|
851
|
+
str(fps_int),
|
|
852
|
+
"-i",
|
|
853
|
+
"pipe:0",
|
|
854
|
+
]
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
if input_pix_fmt is None:
|
|
858
|
+
cmd.extend(["-vf", f"scale={width}:{height}"])
|
|
859
|
+
|
|
860
|
+
cmd.extend(
|
|
861
|
+
[
|
|
862
|
+
"-c:v",
|
|
863
|
+
codec_name,
|
|
864
|
+
"-pix_fmt",
|
|
865
|
+
"yuv420p",
|
|
866
|
+
"-g",
|
|
867
|
+
str(gop_size),
|
|
868
|
+
"-bf",
|
|
869
|
+
"0",
|
|
870
|
+
]
|
|
871
|
+
)
|
|
872
|
+
if bit_rate is not None:
|
|
873
|
+
cmd.extend(["-b:v", str(bit_rate)])
|
|
874
|
+
for key, value in options.items():
|
|
875
|
+
cmd.extend([f"-{key}", value])
|
|
876
|
+
|
|
877
|
+
cmd.extend(["-movflags", "+faststart", "-f", "mp4", str(output_path)])
|
|
878
|
+
|
|
879
|
+
self._cmd = cmd
|
|
880
|
+
self.output_path = output_path
|
|
881
|
+
self.config = EncoderConfig(width=width, height=height, codec_name=codec_name)
|
|
882
|
+
self._stderr_lines: list[str] = []
|
|
883
|
+
self._frames_fed = 0
|
|
884
|
+
|
|
885
|
+
self._process = subprocess.Popen( # noqa: S603 — args list, not shell
|
|
886
|
+
cmd,
|
|
887
|
+
stdin=subprocess.PIPE,
|
|
888
|
+
stdout=subprocess.DEVNULL,
|
|
889
|
+
stderr=subprocess.PIPE,
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
|
|
893
|
+
self._stderr_thread.start()
|
|
894
|
+
|
|
895
|
+
def _read_stderr(self) -> None:
|
|
896
|
+
if self._process.stderr is None:
|
|
897
|
+
return
|
|
898
|
+
for raw in iter(self._process.stderr.readline, b""):
|
|
899
|
+
line = raw.decode("utf-8", errors="replace").rstrip()
|
|
900
|
+
if line:
|
|
901
|
+
self._stderr_lines.append(line)
|
|
902
|
+
self._process.stderr.close()
|
|
903
|
+
|
|
904
|
+
def write_frame(self, frame_bytes: bytes) -> None:
|
|
905
|
+
"""Write one input frame to ffmpeg stdin."""
|
|
906
|
+
if self._process.stdin is None or self._process.stdin.closed:
|
|
907
|
+
raise VideoEncoderError("ffmpeg process stdin is closed")
|
|
908
|
+
try:
|
|
909
|
+
self._process.stdin.write(frame_bytes)
|
|
910
|
+
except BrokenPipeError as exc:
|
|
911
|
+
stderr_tail = "\n".join(self._stderr_lines[-5:])
|
|
912
|
+
raise VideoEncoderError(f"ffmpeg subprocess exited mid-stream:\n{stderr_tail}") from exc
|
|
913
|
+
self._frames_fed += 1
|
|
914
|
+
|
|
915
|
+
@property
|
|
916
|
+
def frames_fed(self) -> int:
|
|
917
|
+
return self._frames_fed
|
|
918
|
+
|
|
919
|
+
def close(self) -> None:
|
|
920
|
+
if self._process.poll() is not None and self._process.stdin is None:
|
|
921
|
+
return
|
|
922
|
+
if self._process.stdin and not self._process.stdin.closed:
|
|
923
|
+
with contextlib.suppress(BrokenPipeError):
|
|
924
|
+
self._process.stdin.close()
|
|
925
|
+
try:
|
|
926
|
+
self._process.wait(timeout=30)
|
|
927
|
+
except subprocess.TimeoutExpired:
|
|
928
|
+
self._process.kill()
|
|
929
|
+
self._process.wait(timeout=5)
|
|
930
|
+
raise VideoEncoderError("ffmpeg subprocess did not exit cleanly; killed") from None
|
|
931
|
+
self._stderr_thread.join(timeout=5)
|
|
932
|
+
if self._process.returncode != 0:
|
|
933
|
+
stderr_tail = "\n".join(self._stderr_lines[-10:])
|
|
934
|
+
raise VideoEncoderError(
|
|
935
|
+
f"ffmpeg exited with code {self._process.returncode}:\n{stderr_tail}"
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
def __del__(self) -> None:
|
|
939
|
+
try:
|
|
940
|
+
if self._process and self._process.poll() is None:
|
|
941
|
+
self._process.kill()
|
|
942
|
+
self._process.wait(timeout=2)
|
|
943
|
+
except Exception: # noqa: BLE001, S110
|
|
944
|
+
pass
|