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.
@@ -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