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,283 @@
1
+ """Shared encoder types, config, and option builders — no PyAV dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+ import numpy as np
14
+
15
+ from mcap_codec_support._protocols import RawImageMessage
16
+
17
+
18
+ class VideoEncoderError(Exception):
19
+ """Raised when encoding fails."""
20
+
21
+
22
+ class VideoCodec(str, Enum):
23
+ H264 = "h264"
24
+ H265 = "h265"
25
+ VP9 = "vp9"
26
+ AV1 = "av1"
27
+
28
+
29
+ class EncoderBackend(str, Enum):
30
+ AUTO = "auto"
31
+ SOFTWARE = "software"
32
+ VIDEOTOOLBOX = "videotoolbox"
33
+ NVENC = "nvenc"
34
+ VAAPI = "vaapi"
35
+
36
+
37
+ class EncoderMode(str, Enum):
38
+ """Which video encoder backend to use."""
39
+
40
+ AUTO = "auto"
41
+ PYAV = "pyav"
42
+ FFMPEG_CLI = "ffmpeg-cli"
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class EncoderConfig:
47
+ """Configuration for a video encoder."""
48
+
49
+ width: int
50
+ height: int
51
+ codec_name: str
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class DecompressedFrame:
56
+ """Result of decompressing a single video frame."""
57
+
58
+ data: bytes
59
+ """JPEG bytes (when ``is_jpeg=True``) or raw RGB24 bytes."""
60
+ width: int
61
+ height: int
62
+ is_jpeg: bool
63
+
64
+
65
+ DEFAULT_FPS: float = 30.0
66
+ DEFAULT_GOP_SIZE: int = 30
67
+
68
+ # Mapping from short codec name to software (CPU) encoder name.
69
+ SOFTWARE_CODEC_MAP: dict[str, str] = {
70
+ "h264": "libx264",
71
+ "h265": "libx265",
72
+ "vp9": "libvpx-vp9",
73
+ "av1": "libaom-av1",
74
+ }
75
+
76
+ # Mapping from short codec name to hardware encoder names per backend.
77
+ HARDWARE_CODEC_MAP: dict[str, dict[str, str]] = {
78
+ "h264": {
79
+ "videotoolbox": "h264_videotoolbox",
80
+ "nvenc": "h264_nvenc",
81
+ "vaapi": "h264_vaapi",
82
+ },
83
+ "h265": {
84
+ "videotoolbox": "hevc_videotoolbox",
85
+ "nvenc": "hevc_nvenc",
86
+ "vaapi": "hevc_vaapi",
87
+ },
88
+ }
89
+
90
+
91
+ def get_software_encoder(codec: str) -> str:
92
+ """Return the software encoder name for *codec*.
93
+
94
+ Raises:
95
+ ValueError: If *codec* is not in SOFTWARE_CODEC_MAP.
96
+ """
97
+ sw = SOFTWARE_CODEC_MAP.get(codec)
98
+ if not sw:
99
+ raise ValueError(f"Unsupported codec '{codec}'. Supported: {', '.join(SOFTWARE_CODEC_MAP)}")
100
+ return sw
101
+
102
+
103
+ def build_encoder_options(
104
+ codec_name: str,
105
+ quality: int,
106
+ width: int,
107
+ height: int,
108
+ *,
109
+ preset: str | None = None,
110
+ ) -> tuple[dict[str, str], int | None]:
111
+ """Build codec-specific encoder options from user-facing quality (CRF).
112
+
113
+ Returns (options_dict, bit_rate_or_none).
114
+ """
115
+ if codec_name == "libx264":
116
+ return {
117
+ "crf": str(quality),
118
+ "preset": preset or "superfast",
119
+ "tune": "zerolatency",
120
+ }, None
121
+ if codec_name == "libx265":
122
+ return {
123
+ "crf": str(quality),
124
+ "preset": preset or "superfast",
125
+ }, None
126
+ if codec_name in {"h264_videotoolbox", "hevc_videotoolbox"}:
127
+ pixel_scale = (width * height) / (1920 * 1080)
128
+ bit_rate = int(5_000_000 * (2 ** ((28 - quality) / 6)) * pixel_scale)
129
+ return {}, bit_rate
130
+ if codec_name in {"h264_nvenc", "hevc_nvenc"}:
131
+ return {"rc": "vbr", "cq": str(quality)}, None
132
+ if codec_name in {"h264_vaapi", "hevc_vaapi"}:
133
+ return {"qp": str(quality)}, None
134
+ if codec_name == "libvpx-vp9":
135
+ opts: dict[str, str] = {"cpu-used": "4", "crf": str(quality)}
136
+ if preset:
137
+ opts["deadline"] = preset
138
+ return opts, None
139
+ if codec_name == "libaom-av1":
140
+ opts = {"cpu-used": "6", "crf": str(quality)}
141
+ if preset:
142
+ opts["usage"] = preset
143
+ return opts, None
144
+ return {}, None
145
+
146
+
147
+ def get_encoder_options(codec: VideoCodec, encoder_name: str) -> dict[str, str]:
148
+ """Return encoder preset options for bitrate-mode encoding (e.g. file output)."""
149
+ options: dict[str, str] = {}
150
+ if "nvenc" in encoder_name:
151
+ options["preset"] = "p4"
152
+ elif (
153
+ codec in (VideoCodec.H264, VideoCodec.H265) and "libx264" in encoder_name
154
+ ) or "libx265" in encoder_name:
155
+ options["preset"] = "medium"
156
+ return options
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Dimension helpers
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ def calculate_downscale_dimensions(width: int, height: int, max_dimension: int) -> tuple[int, int]:
165
+ """Downscale dimensions to fit within max_dimension, preserving aspect ratio.
166
+
167
+ Ensures both dimensions are even (required for yuv420p).
168
+ """
169
+
170
+ def ensure_even(value: int) -> int:
171
+ return value if value % 2 == 0 else max(value - 1, 2)
172
+
173
+ if width <= max_dimension and height <= max_dimension:
174
+ return ensure_even(width), ensure_even(height)
175
+
176
+ aspect_ratio = width / height
177
+ if width > height:
178
+ new_width = max_dimension
179
+ new_height = int(new_width / aspect_ratio)
180
+ else:
181
+ new_height = max_dimension
182
+ new_width = int(new_height * aspect_ratio)
183
+
184
+ return ensure_even(new_width), ensure_even(new_height)
185
+
186
+
187
+ def raw_image_to_array(message: RawImageMessage) -> np.ndarray:
188
+ """Convert a ROS Image message to an RGB numpy array."""
189
+ import numpy as np # noqa: PLC0415
190
+
191
+ if not message.data:
192
+ raise VideoEncoderError("Image has no data")
193
+
194
+ width = message.width
195
+ height = message.height
196
+ encoding = str(message.encoding).lower()
197
+ data = bytes(message.data)
198
+
199
+ if encoding in {"rgb", "rgb8"}:
200
+ array = np.frombuffer(data, dtype=np.uint8).reshape(height, width, 3)
201
+ return array.copy()
202
+ if encoding in {"bgr", "bgr8"}:
203
+ array = np.frombuffer(data, dtype=np.uint8).reshape(height, width, 3)
204
+ return array[..., ::-1].copy()
205
+ if encoding in {"mono", "mono8", "8uc1"}:
206
+ mono_array = np.frombuffer(data, dtype=np.uint8).reshape(height, width)
207
+ return np.repeat(mono_array[:, :, None], 3, axis=2)
208
+
209
+ raise VideoEncoderError(f"Unsupported image encoding: {message.encoding}")
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Encoder resolution
214
+ # ---------------------------------------------------------------------------
215
+
216
+ # Platform → hardware backend probe order.
217
+ _HW_PROBE_ORDER: dict[str, list[str]] = {
218
+ "Darwin": ["videotoolbox"],
219
+ "Linux": ["nvenc", "vaapi"],
220
+ }
221
+
222
+
223
+ def resolve_encoder(
224
+ codec: str,
225
+ *,
226
+ test_fn: Callable[[str], bool],
227
+ use_hardware: bool = True,
228
+ ) -> str:
229
+ """Pick the best available encoder for *codec*.
230
+
231
+ Probes hardware encoders first (when *use_hardware* is True), then
232
+ falls back to the software encoder. Uses *test_fn* to check whether
233
+ a given encoder name is available on the system.
234
+
235
+ Raises:
236
+ ValueError: If no encoder is found for *codec*.
237
+ """
238
+ if use_hardware:
239
+ hw = HARDWARE_CODEC_MAP.get(codec)
240
+ if hw:
241
+ for backend_name in _HW_PROBE_ORDER.get(platform.system(), []):
242
+ encoder = hw.get(backend_name)
243
+ if encoder and test_fn(encoder):
244
+ return encoder
245
+ return get_software_encoder(codec)
246
+
247
+
248
+ def resolve_encoder_for_backend(
249
+ codec: str,
250
+ backend: str,
251
+ *,
252
+ test_fn: Callable[[str], bool],
253
+ ) -> str:
254
+ """Pick the encoder for *codec* using the specified *backend*.
255
+
256
+ *backend* must be one of the ``EncoderBackend`` values: ``"auto"``,
257
+ ``"software"``, ``"videotoolbox"``, ``"nvenc"``, or ``"vaapi"``.
258
+
259
+ Raises:
260
+ VideoEncoderError: If the encoder is unavailable or the backend is unknown.
261
+ """
262
+ if backend == "auto":
263
+ try:
264
+ return resolve_encoder(codec, test_fn=test_fn)
265
+ except ValueError as exc:
266
+ raise VideoEncoderError(str(exc)) from exc
267
+
268
+ if backend == "software":
269
+ try:
270
+ return get_software_encoder(codec)
271
+ except ValueError as exc:
272
+ raise VideoEncoderError(str(exc)) from exc
273
+
274
+ # Explicit hardware backend.
275
+ hw = HARDWARE_CODEC_MAP.get(codec, {})
276
+ encoder = hw.get(backend)
277
+ if not encoder:
278
+ raise VideoEncoderError(f"Hardware encoder '{backend}' not available for codec: {codec}")
279
+ if not test_fn(encoder):
280
+ raise VideoEncoderError(
281
+ f"Hardware encoder '{encoder}' not available on this system. Try --encoder software."
282
+ )
283
+ return encoder
@@ -0,0 +1,269 @@
1
+ """Video compression, image transcoding, and decompressor selection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from mcap_codec_support._schemas import normalize_schema_name
9
+ from mcap_codec_support.video.common import (
10
+ DEFAULT_FPS,
11
+ DEFAULT_GOP_SIZE,
12
+ EncoderMode,
13
+ VideoEncoderError,
14
+ calculate_downscale_dimensions,
15
+ raw_image_to_array,
16
+ )
17
+ from mcap_codec_support.video.schemas import COMPRESSED_SCHEMAS
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Iterable, Iterator
21
+ from concurrent.futures import Future, ThreadPoolExecutor
22
+
23
+ from small_mcap import DecodedMessage
24
+
25
+ from mcap_codec_support._protocols import VideoCompressionBackend, VideoDecompressorProtocol
26
+
27
+
28
+ class _PyAVCompressionBackend:
29
+ label = "pyav"
30
+ prefetch_supported = True
31
+
32
+ def test_encoder(self, encoder_name: str) -> bool:
33
+ from mcap_codec_support.video.pyav import test_encoder # noqa: PLC0415
34
+
35
+ return test_encoder(encoder_name)
36
+
37
+ def resolve_encoder(self, codec: str) -> str:
38
+ from mcap_codec_support.video.pyav import resolve_encoder # noqa: PLC0415
39
+
40
+ return resolve_encoder(codec)
41
+
42
+ def decode_compressed(self, data: bytes) -> tuple[Any, int, int]:
43
+ from mcap_codec_support.video.pyav import decode_compressed_frame # noqa: PLC0415
44
+
45
+ frame = decode_compressed_frame(data)
46
+ return frame, frame.width, frame.height
47
+
48
+ def decode_image(self, msg: DecodedMessage, schema_name: str) -> tuple[Any, int, int]:
49
+ if schema_name in COMPRESSED_SCHEMAS:
50
+ return self.decode_compressed(bytes(msg.decoded_message.data))
51
+
52
+ import av # noqa: PLC0415
53
+
54
+ rgb_array = raw_image_to_array(msg.decoded_message)
55
+ frame = av.VideoFrame.from_ndarray(rgb_array, format="rgb24")
56
+ return frame, frame.width, frame.height
57
+
58
+ def create_encoder(
59
+ self,
60
+ width: int,
61
+ height: int,
62
+ codec_name: str,
63
+ quality: int,
64
+ *,
65
+ input_pix_fmt: str | None = None, # noqa: ARG002
66
+ scale: tuple[int, int] | None = None, # noqa: ARG002
67
+ ) -> Any:
68
+ from mcap_codec_support.video.pyav import VideoEncoder # noqa: PLC0415
69
+
70
+ return VideoEncoder(
71
+ width=width,
72
+ height=height,
73
+ codec_name=codec_name,
74
+ quality=quality,
75
+ target_fps=DEFAULT_FPS,
76
+ gop_size=DEFAULT_GOP_SIZE,
77
+ )
78
+
79
+ def get_pix_fmt(self, topic: str) -> str | None:
80
+ del topic
81
+ return None
82
+
83
+
84
+ class _FfmpegCliCompressionBackend:
85
+ label = "ffmpeg-cli"
86
+ prefetch_supported = False
87
+
88
+ def __init__(self) -> None:
89
+ self._topic_pix_fmt: dict[str, str | None] = {}
90
+
91
+ def get_pix_fmt(self, topic: str) -> str | None:
92
+ return self._topic_pix_fmt.get(topic)
93
+
94
+ def test_encoder(self, encoder_name: str) -> bool:
95
+ from mcap_codec_support.video.ffmpeg import check_encoder_cli # noqa: PLC0415
96
+
97
+ return check_encoder_cli(encoder_name)
98
+
99
+ def resolve_encoder(self, codec: str) -> str:
100
+ from mcap_codec_support.video.ffmpeg import resolve_encoder # noqa: PLC0415
101
+
102
+ return resolve_encoder(codec)
103
+
104
+ def decode_compressed(self, data: bytes) -> tuple[Any, int, int]:
105
+ from mcap_codec_support.video.ffmpeg import probe_image_dimensions # noqa: PLC0415
106
+
107
+ width, height = probe_image_dimensions(data)
108
+ return data, width, height
109
+
110
+ def decode_image(self, msg: DecodedMessage, schema_name: str) -> tuple[Any, int, int]:
111
+ data = bytes(msg.decoded_message.data)
112
+ topic = msg.channel.topic
113
+
114
+ if schema_name in COMPRESSED_SCHEMAS:
115
+ self._topic_pix_fmt[topic] = None
116
+ frame, width, height = self.decode_compressed(data)
117
+ return frame, width, height
118
+
119
+ from mcap_codec_support.video.ffmpeg import ROS_ENCODING_TO_PIX_FMT # noqa: PLC0415
120
+
121
+ encoding = str(msg.decoded_message.encoding).lower()
122
+ pix_fmt = ROS_ENCODING_TO_PIX_FMT.get(encoding)
123
+ if not pix_fmt:
124
+ raise VideoEncoderError(f"Unsupported image encoding: {msg.decoded_message.encoding}")
125
+ self._topic_pix_fmt[topic] = pix_fmt
126
+ return data, msg.decoded_message.width, msg.decoded_message.height
127
+
128
+ def create_encoder(
129
+ self,
130
+ width: int,
131
+ height: int,
132
+ codec_name: str,
133
+ quality: int,
134
+ *,
135
+ input_pix_fmt: str | None = None,
136
+ scale: tuple[int, int] | None = None,
137
+ ) -> Any:
138
+ from mcap_codec_support.video.ffmpeg import FFmpegVideoEncoder # noqa: PLC0415
139
+
140
+ return FFmpegVideoEncoder(
141
+ width=width,
142
+ height=height,
143
+ codec_name=codec_name,
144
+ quality=quality,
145
+ target_fps=DEFAULT_FPS,
146
+ gop_size=DEFAULT_GOP_SIZE,
147
+ input_pix_fmt=input_pix_fmt,
148
+ scale=scale,
149
+ )
150
+
151
+
152
+ def create_video_compression_backend(
153
+ mode: EncoderMode, codec: str, *, do_video: bool
154
+ ) -> VideoCompressionBackend:
155
+ """Select the roscompress video backend."""
156
+ if mode is EncoderMode.FFMPEG_CLI:
157
+ return _FfmpegCliCompressionBackend()
158
+
159
+ pyav_backend = _PyAVCompressionBackend()
160
+ if mode is EncoderMode.AUTO and do_video:
161
+ try:
162
+ pyav_backend.resolve_encoder(codec)
163
+ except (ImportError, ValueError):
164
+ return _FfmpegCliCompressionBackend()
165
+ return pyav_backend
166
+
167
+
168
+ def prefetch_image_decodes(
169
+ messages: Iterable[DecodedMessage],
170
+ backend: VideoCompressionBackend,
171
+ pool: ThreadPoolExecutor,
172
+ prefetch: int = 8,
173
+ ) -> Iterator[tuple[DecodedMessage, Future[Any] | None]]:
174
+ """Wrap message iterator to decode compressed images in background threads."""
175
+ buffer: deque[tuple[DecodedMessage, Future[Any] | None]] = deque()
176
+
177
+ for msg in messages:
178
+ schema_name = normalize_schema_name(msg.schema.name) if msg.schema else ""
179
+ if schema_name in COMPRESSED_SCHEMAS:
180
+ data = bytes(msg.decoded_message.data)
181
+ future: Future[Any] | None = pool.submit(backend.decode_compressed, data)
182
+ else:
183
+ future = None
184
+ buffer.append((msg, future))
185
+
186
+ if len(buffer) > prefetch:
187
+ yield buffer.popleft()
188
+
189
+ while buffer:
190
+ yield buffer.popleft()
191
+
192
+
193
+ def _encode_rgb_array_to_jpeg(rgb_array: Any, quality: int) -> bytes:
194
+ try:
195
+ import imagecodecs # noqa: PLC0415
196
+ except ImportError as exc:
197
+ raise VideoEncoderError(
198
+ "imagecodecs is required for JPEG image encoding. "
199
+ "Install with: uv add 'mcap-codec-support[image]'"
200
+ ) from exc
201
+ return bytes(imagecodecs.jpeg_encode(rgb_array, level=quality))
202
+
203
+
204
+ def _resize_rgb_array(rgb_array: Any, width: int, height: int) -> Any:
205
+ import av # noqa: PLC0415
206
+
207
+ frame = av.VideoFrame.from_ndarray(rgb_array, format="rgb24")
208
+ return frame.reformat(width=width, height=height, format="rgb24").to_ndarray(format="rgb24")
209
+
210
+
211
+ def encode_raw_image_to_jpeg(
212
+ decoded_message: Any, *, jpeg_quality: int, scale: int | None
213
+ ) -> tuple[bytes, int, int]:
214
+ """Encode a raw ROS Image message to JPEG using imagecodecs for final encode."""
215
+ rgb_array = raw_image_to_array(decoded_message)
216
+ src_h, src_w = rgb_array.shape[:2]
217
+ if scale is not None:
218
+ target_w, target_h = calculate_downscale_dimensions(src_w, src_h, scale)
219
+ else:
220
+ target_w, target_h = src_w, src_h
221
+
222
+ target_w -= target_w % 2
223
+ target_h -= target_h % 2
224
+ if target_w < 2 or target_h < 2:
225
+ raise VideoEncoderError(f"Source frame too small ({target_w}x{target_h}) for JPEG encoding")
226
+
227
+ if target_w != src_w or target_h != src_h:
228
+ rgb_array = _resize_rgb_array(rgb_array, target_w, target_h)
229
+
230
+ return _encode_rgb_array_to_jpeg(rgb_array, jpeg_quality), target_w, target_h
231
+
232
+
233
+ def decode_compressed_image_to_rgb_array(data: bytes) -> Any:
234
+ """Decode JPEG/PNG compressed image bytes to an RGB numpy array."""
235
+ from mcap_codec_support.video.pyav import decode_compressed_frame # noqa: PLC0415
236
+
237
+ return decode_compressed_frame(data).to_ndarray(format="rgb24")
238
+
239
+
240
+ def create_video_decompressor(
241
+ video_format: str = "compressed",
242
+ jpeg_quality: int = 90,
243
+ *,
244
+ mode: EncoderMode = EncoderMode.AUTO,
245
+ ) -> VideoDecompressorProtocol:
246
+ """Create a video decompressor using the requested backend."""
247
+ if mode == EncoderMode.PYAV:
248
+ from mcap_codec_support.video.pyav import PyAVVideoDecompressor # noqa: PLC0415
249
+
250
+ return PyAVVideoDecompressor(video_format=video_format, jpeg_quality=jpeg_quality)
251
+
252
+ if mode == EncoderMode.FFMPEG_CLI:
253
+ from mcap_codec_support.video.ffmpeg import FFmpegVideoDecompressor # noqa: PLC0415
254
+
255
+ return FFmpegVideoDecompressor(video_format=video_format, jpeg_quality=jpeg_quality)
256
+
257
+ try:
258
+ from mcap_codec_support.video.pyav import PyAVVideoDecompressor # noqa: PLC0415
259
+
260
+ return PyAVVideoDecompressor(video_format=video_format, jpeg_quality=jpeg_quality)
261
+ except ImportError:
262
+ from mcap_codec_support.video.ffmpeg import ( # noqa: PLC0415
263
+ FFmpegVideoDecompressor,
264
+ find_ffmpeg,
265
+ )
266
+
267
+ if find_ffmpeg():
268
+ return FFmpegVideoDecompressor(video_format=video_format, jpeg_quality=jpeg_quality)
269
+ raise
@@ -0,0 +1,123 @@
1
+ """Decoder factory for decompressing CompressedVideo topics.
2
+
3
+ Provides ``VideoDecompressFactory`` for use with ``read_message_decoded``.
4
+ Uses ``VideoDecompressorProtocol`` — no direct ``av`` or ``subprocess`` imports.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Literal
10
+
11
+ from mcap_codec_support.video.common import EncoderMode
12
+ from mcap_codec_support.video.schemas import COMPRESSED_VIDEO_SCHEMA
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from small_mcap import Channel, Schema
18
+
19
+ from mcap_codec_support._messages import CompressedImageDict, Header, ImageDict
20
+ from mcap_codec_support._protocols import VideoDecompressorProtocol
21
+ from mcap_codec_support.video.common import DecompressedFrame
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # VideoDecompressFactory
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ class VideoDecompressFactory:
30
+ """Channel-aware decoder factory: CompressedVideo → CompressedImage or Image.
31
+
32
+ Creates a separate ``VideoDecompressorProtocol`` per channel for proper
33
+ P-frame handling. No direct ``av`` or ``subprocess`` imports.
34
+ """
35
+
36
+ channel_aware = True
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ video_format: Literal["compressed", "raw"] = "compressed",
42
+ jpeg_quality: int = 90,
43
+ backend: EncoderMode = EncoderMode.AUTO,
44
+ ) -> None:
45
+ self._video_format = video_format
46
+ self._jpeg_quality = jpeg_quality
47
+ self._backend = backend
48
+
49
+ from mcap_ros2_support_fast.decoder import DecoderFactory # noqa: PLC0415
50
+
51
+ self._cdr_factory = DecoderFactory()
52
+ self._decompressors: dict[int, VideoDecompressorProtocol] = {}
53
+
54
+ def flush_all(self) -> list[DecompressedFrame]:
55
+ """Flush all decompressors and return remaining frames."""
56
+ return [frame for _, frame in self.flush_all_by_channel()]
57
+
58
+ def flush_all_by_channel(self) -> list[tuple[int, DecompressedFrame]]:
59
+ """Flush all decompressors and keep channel ownership for each frame."""
60
+ frames: list[tuple[int, DecompressedFrame]] = []
61
+ for channel_id, decompressor in self._decompressors.items():
62
+ frames.extend((channel_id, frame) for frame in decompressor.flush())
63
+ return frames
64
+
65
+ def _get_decompressor(self, channel_id: int) -> VideoDecompressorProtocol:
66
+ if channel_id not in self._decompressors:
67
+ from mcap_codec_support.video.compression import ( # noqa: PLC0415
68
+ create_video_decompressor,
69
+ )
70
+
71
+ self._decompressors[channel_id] = create_video_decompressor(
72
+ video_format=self._video_format,
73
+ jpeg_quality=self._jpeg_quality,
74
+ mode=self._backend,
75
+ )
76
+ return self._decompressors[channel_id]
77
+
78
+ def decoder_for(
79
+ self,
80
+ message_encoding: str,
81
+ schema: Schema | None,
82
+ channel: Channel,
83
+ ) -> Callable[[bytes | memoryview], CompressedImageDict | ImageDict | None] | None:
84
+ if schema is None or schema.name != COMPRESSED_VIDEO_SCHEMA:
85
+ return None
86
+
87
+ cdr_decoder = self._cdr_factory.decoder_for(message_encoding, schema)
88
+ if cdr_decoder is None:
89
+ return None
90
+
91
+ decompressor = self._get_decompressor(channel.id)
92
+
93
+ def _decode(data: bytes | memoryview) -> CompressedImageDict | ImageDict | None:
94
+ decoded = cdr_decoder(data)
95
+ codec = decoded.format
96
+ video_data = decoded.data
97
+ if isinstance(video_data, memoryview):
98
+ video_data = bytes(video_data)
99
+
100
+ frame: DecompressedFrame | None = decompressor.decompress(video_data, codec)
101
+ if frame is None:
102
+ return None
103
+
104
+ timestamp = decoded.timestamp
105
+ header: Header = {
106
+ "stamp": {"sec": timestamp.sec, "nanosec": timestamp.nanosec},
107
+ "frame_id": decoded.frame_id,
108
+ }
109
+
110
+ if frame.is_jpeg:
111
+ return {"header": header, "format": "jpeg", "data": frame.data}
112
+
113
+ return {
114
+ "header": header,
115
+ "height": frame.height,
116
+ "width": frame.width,
117
+ "encoding": "rgb8",
118
+ "is_bigendian": 0,
119
+ "step": frame.width * 3,
120
+ "data": frame.data,
121
+ }
122
+
123
+ return _decode