Skip to content

Commit aca83b0

Browse files
authored
Merge pull request #1283 from roflcoopter/feature/ffmpeg-mjpeg-transcode
fix(ffmpeg) auto transcode to h264 for mjpeg sources
2 parents a842277 + c1ffe1b commit aca83b0

File tree

7 files changed

+180
-46
lines changed

7 files changed

+180
-46
lines changed

docs/src/pages/components-explorer/components/ffmpeg/config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2377,9 +2377,9 @@
23772377
{
23782378
"type": "string",
23792379
"name": "codec",
2380-
"description": "FFmpeg video encoder codec, eg <code>h264_nvenc</code>.",
2380+
"description": "FFmpeg video encoder codec, eg <code>h264_nvenc</code>. Defaults to <code>copy</code> except for MJPEG streams where the default is <code>h264</code>.",
23812381
"optional": true,
2382-
"default": "copy"
2382+
"default": null
23832383
},
23842384
{
23852385
"type": "string",

docs/src/pages/components-explorer/components/gstreamer/config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2119,9 +2119,9 @@
21192119
{
21202120
"type": "string",
21212121
"name": "codec",
2122-
"description": "FFmpeg video encoder codec, eg <code>h264_nvenc</code>.",
2122+
"description": "FFmpeg video encoder codec, eg <code>h264_nvenc</code>. Defaults to <code>copy</code> except for MJPEG streams where the default is <code>h264</code>.",
21232123
"optional": true,
2124-
"default": "copy"
2124+
"default": null
21252125
},
21262126
{
21272127
"type": "string",

tests/components/ffmpeg/test_stream.py

Lines changed: 123 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""FFmpeg stream tests."""
2+
23
from __future__ import annotations
34

45
from contextlib import nullcontext
@@ -23,6 +24,7 @@
2324
CONFIG_PROTOCOL,
2425
CONFIG_RECORDER,
2526
CONFIG_RECORDER_AUDIO_CODEC,
27+
CONFIG_RECORDER_CODEC,
2628
CONFIG_STREAM_FORMAT,
2729
CONFIG_SUBSTREAM,
2830
CONFIG_USERNAME,
@@ -40,12 +42,14 @@
4042
)
4143
from viseron.components.ffmpeg.stream import FFprobe, Stream
4244
from viseron.const import (
45+
CAMERA_SEGMENT_DURATION,
4346
ENV_CUDA_SUPPORTED,
4447
ENV_JETSON_NANO,
4548
ENV_RASPBERRYPI3,
4649
ENV_RASPBERRYPI4,
4750
)
4851
from viseron.exceptions import StreamInformationError
52+
from viseron.helpers.validators import UNDEFINED
4953

5054
from tests.common import MockCamera
5155

@@ -71,19 +75,17 @@
7175

7276
CONFIG_WITH_SUBSTREAM: dict[str, Any] = {
7377
**CONFIG,
74-
**{
75-
CONFIG_SUBSTREAM: {
76-
CONFIG_PROTOCOL: DEFAULT_PROTOCOL,
77-
CONFIG_STREAM_FORMAT: DEFAULT_STREAM_FORMAT,
78-
CONFIG_PORT: 1234,
79-
CONFIG_PATH: "/",
80-
CONFIG_WIDTH: 1921,
81-
CONFIG_HEIGHT: 1081,
82-
CONFIG_FPS: 31,
83-
CONFIG_CODEC: "h265",
84-
CONFIG_AUDIO_CODEC: DEFAULT_AUDIO_CODEC,
85-
CONFIG_PIX_FMT: "yuv420p",
86-
},
78+
CONFIG_SUBSTREAM: {
79+
CONFIG_PROTOCOL: DEFAULT_PROTOCOL,
80+
CONFIG_STREAM_FORMAT: DEFAULT_STREAM_FORMAT,
81+
CONFIG_PORT: 1234,
82+
CONFIG_PATH: "/",
83+
CONFIG_WIDTH: 1921,
84+
CONFIG_HEIGHT: 1081,
85+
CONFIG_FPS: 31,
86+
CONFIG_CODEC: "h265",
87+
CONFIG_AUDIO_CODEC: DEFAULT_AUDIO_CODEC,
88+
CONFIG_PIX_FMT: "yuv420p",
8789
},
8890
}
8991

@@ -140,16 +142,20 @@ def test_init(
140142
) -> None:
141143
"""Test that the stream is correctly initialized."""
142144
mocked_camera = MockCamera(identifier="test_camera_identifier")
143-
with raises, patch.object(
144-
FFprobe, "stream_information", MagicMock(return_value=stream_information)
145-
) as mock_stream_information, patch.object(
146-
Stream, "create_symlink", MagicMock()
145+
with (
146+
raises,
147+
patch.object(
148+
FFprobe,
149+
"stream_information",
150+
MagicMock(return_value=stream_information),
151+
) as mock_stream_information,
152+
patch.object(Stream, "create_symlink", MagicMock()),
147153
):
148154
stream = Stream(config, mocked_camera, "test_camera_identifier")
149155
assert mock_stream_information.call_count == (
150156
2 if config.get(CONFIG_SUBSTREAM) else 1
151157
)
152-
assert stream._camera == mocked_camera # pylint: disable=protected-access
158+
assert stream._camera == mocked_camera
153159
assert stream.width == expected_width
154160
assert stream.height == expected_height
155161
assert stream.fps == expected_fps
@@ -180,7 +186,7 @@ def test_get_decoder_codec(
180186
Stream, "__init__", MagicMock(spec=Stream, return_value=None)
181187
):
182188
stream = Stream(config, mocked_camera, "test_camera_identifier")
183-
stream._logger = MagicMock() # pylint: disable=protected-access
189+
stream._logger = MagicMock()
184190
assert stream.get_decoder_codec(config, stream_codec) == expected_cmd
185191

186192
@pytest.mark.parametrize(
@@ -206,8 +212,8 @@ def test_get_encoder_audio_codec(
206212
Stream, "__init__", MagicMock(spec=Stream, return_value=None)
207213
):
208214
stream = Stream(config, mocked_camera, "test_camera_identifier")
209-
stream._logger = MagicMock() # pylint: disable=protected-access
210-
stream._config = config # pylint: disable=protected-access
215+
stream._logger = MagicMock()
216+
stream._config = config
211217
assert (
212218
stream.get_encoder_audio_codec(stream_audio_codec) == expected_audio_cmd
213219
)
@@ -235,7 +241,7 @@ def test_get_stream_url(self, username, password, expected_url) -> None:
235241
Stream, "__init__", MagicMock(spec=Stream, return_value=None)
236242
):
237243
stream = Stream(CONFIG, mocked_camera, "test_camera_identifier")
238-
stream._config = config # pylint: disable=protected-access
244+
stream._config = config
239245
assert stream.get_stream_url(config) == expected_url
240246

241247
def test_get_stream_information(self):
@@ -245,10 +251,11 @@ def test_get_stream_information(self):
245251
config[CONFIG_CODEC] = DEFAULT_CODEC
246252
config[CONFIG_AUDIO_CODEC] = DEFAULT_AUDIO_CODEC
247253

248-
with patch.object(
249-
Stream, "__init__", MagicMock(spec=Stream, return_value=None)
250-
), patch.object(
251-
Stream, "get_stream_url", MagicMock(return_value="test_stream_url")
254+
with (
255+
patch.object(Stream, "__init__", MagicMock(spec=Stream, return_value=None)),
256+
patch.object(
257+
Stream, "get_stream_url", MagicMock(return_value="test_stream_url")
258+
),
252259
):
253260
stream = Stream(config, mocked_camera, "test_camera_identifier")
254261
mock_ffprobe = MagicMock(spec=FFprobe)
@@ -259,9 +266,9 @@ def test_get_stream_information(self):
259266
"h264",
260267
"aac",
261268
)
262-
stream._ffprobe = mock_ffprobe # pylint: disable=protected-access
269+
stream._ffprobe = mock_ffprobe
263270
mock_logger = MagicMock()
264-
stream._logger = mock_logger # pylint: disable=protected-access
271+
stream._logger = mock_logger
265272

266273
result = stream.get_stream_information(config)
267274

@@ -282,10 +289,11 @@ def test_get_stream_information_missing_parameters(self):
282289
config[CONFIG_CODEC] = DEFAULT_CODEC
283290
config[CONFIG_AUDIO_CODEC] = DEFAULT_AUDIO_CODEC
284291

285-
with patch.object(
286-
Stream, "__init__", MagicMock(spec=Stream, return_value=None)
287-
), patch.object(
288-
Stream, "get_stream_url", MagicMock(return_value="test_stream_url")
292+
with (
293+
patch.object(Stream, "__init__", MagicMock(spec=Stream, return_value=None)),
294+
patch.object(
295+
Stream, "get_stream_url", MagicMock(return_value="test_stream_url")
296+
),
289297
):
290298
stream = Stream(config, mocked_camera, "test_camera_identifier")
291299
mock_ffprobe = MagicMock(spec=FFprobe)
@@ -296,9 +304,9 @@ def test_get_stream_information_missing_parameters(self):
296304
"h264",
297305
"mp4",
298306
)
299-
stream._ffprobe = mock_ffprobe # pylint: disable=protected-access
307+
stream._ffprobe = mock_ffprobe
300308
mock_logger = MagicMock()
301-
stream._logger = mock_logger # pylint: disable=protected-access
309+
stream._logger = mock_logger
302310

303311
with pytest.raises(StreamInformationError) as excinfo:
304312
stream.get_stream_information(config)
@@ -308,3 +316,84 @@ def test_get_stream_information_missing_parameters(self):
308316
mock_ffprobe.stream_information.assert_called_once_with(
309317
"test_stream_url", ANY
310318
)
319+
320+
@pytest.mark.parametrize(
321+
"stream_format, codec, expected_args",
322+
[
323+
("mjpeg", "h264", ["-tune", "zerolatency"]),
324+
("mjpeg", "hevc", []),
325+
("rtsp", None, []),
326+
("rtmp", None, []),
327+
],
328+
)
329+
def test_encoder_tuning_args(self, stream_format, codec, expected_args) -> None:
330+
"""Test that encoder tuning args are only added for MJPEG streams."""
331+
with patch.object(
332+
Stream, "__init__", MagicMock(spec=Stream, return_value=None)
333+
):
334+
stream = Stream.__new__(Stream)
335+
stream._config = {
336+
CONFIG_STREAM_FORMAT: stream_format,
337+
CONFIG_RECORDER: {
338+
CONFIG_RECORDER_CODEC: codec,
339+
},
340+
}
341+
result = stream._encoder_tuning_args()
342+
assert result == expected_args
343+
344+
@pytest.mark.parametrize(
345+
"stream_format, expected_args",
346+
[
347+
(
348+
"mjpeg",
349+
[
350+
"-force_key_frames",
351+
f"expr:gte(t,n_forced*{CAMERA_SEGMENT_DURATION})",
352+
],
353+
),
354+
("rtsp", []),
355+
("rtmp", []),
356+
],
357+
)
358+
def test_force_keyframe_args(self, stream_format, expected_args) -> None:
359+
"""Test that force keyframe args are only added for MJPEG streams."""
360+
with patch.object(
361+
Stream, "__init__", MagicMock(spec=Stream, return_value=None)
362+
):
363+
stream = Stream.__new__(Stream)
364+
stream._config = {
365+
CONFIG_STREAM_FORMAT: stream_format,
366+
}
367+
result = stream._force_keyframe_args()
368+
assert result == expected_args
369+
370+
@pytest.mark.parametrize(
371+
"recorder_codec, stream_format, expected_cmd",
372+
[
373+
# MJPEG with no user-set codec should auto-convert to h264
374+
(UNDEFINED, "mjpeg", ["-c:v", "h264"]),
375+
# Non-MJPEG with no user-set codec should copy
376+
(UNDEFINED, "rtsp", ["-c:v", "copy"]),
377+
(UNDEFINED, "rtmp", ["-c:v", "copy"]),
378+
# User-set codec should always take precedence
379+
("libx264", "mjpeg", ["-c:v", "libx264"]),
380+
("h264_nvenc", "rtsp", ["-c:v", "h264_nvenc"]),
381+
("hevc", "rtmp", ["-c:v", "hevc"]),
382+
],
383+
)
384+
def test_get_encoder_codec(
385+
self, recorder_codec, stream_format, expected_cmd
386+
) -> None:
387+
"""Test that the correct encoder codec is returned."""
388+
with patch.object(
389+
Stream, "__init__", MagicMock(spec=Stream, return_value=None)
390+
):
391+
stream = Stream.__new__(Stream)
392+
stream._logger = MagicMock()
393+
stream._config = {
394+
CONFIG_STREAM_FORMAT: stream_format,
395+
CONFIG_RECORDER: {
396+
CONFIG_RECORDER_CODEC: recorder_codec,
397+
},
398+
}
399+
assert stream.get_encoder_codec() == expected_cmd

viseron/components/ffmpeg/camera.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from viseron.helpers import escape_string, utcnow
2727
from viseron.helpers.logs import SensitiveInformationFilter
2828
from viseron.helpers.validators import (
29+
UNDEFINED,
2930
CameraIdentifier,
3031
CoerceNoneToDict,
3132
Deprecated,
@@ -87,7 +88,6 @@
8788
DEFAULT_RECORD_ONLY,
8889
DEFAULT_RECORDER_AUDIO_CODEC,
8990
DEFAULT_RECORDER_AUDIO_FILTERS,
90-
DEFAULT_RECORDER_CODEC,
9191
DEFAULT_RECORDER_HWACCEL_ARGS,
9292
DEFAULT_RECORDER_OUTPUT_ARGS,
9393
DEFAULT_RECORDER_VIDEO_FILTERS,
@@ -225,9 +225,9 @@ def get_default_hwaccel_args() -> list[str]:
225225
): [str],
226226
vol.Optional(
227227
CONFIG_RECORDER_CODEC,
228-
default=DEFAULT_RECORDER_CODEC,
228+
default=UNDEFINED,
229229
description=DESC_RECORDER_CODEC,
230-
): str,
230+
): Maybe(str),
231231
vol.Optional(
232232
CONFIG_RECORDER_AUDIO_CODEC,
233233
default=DEFAULT_RECORDER_AUDIO_CODEC,

viseron/components/ffmpeg/const.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,15 +153,18 @@
153153
CONFIG_RECORDER_OUPTUT_ARGS = "output_args"
154154

155155
DEFAULT_RECORDER_HWACCEL_ARGS: list[str] = []
156-
DEFAULT_RECORDER_CODEC = "copy"
157156
DEFAULT_RECORDER_AUDIO_CODEC = "unset"
158157
DEFAULT_RECORDER_VIDEO_FILTERS: list[str] = []
159158
DEFAULT_RECORDER_AUDIO_FILTERS: list[str] = []
160159
DEFAULT_RECORDER_OUTPUT_ARGS: list[str] = []
161160
DEFAULT_SEGMENTS_FOLDER = "/segments"
162161

163162
DESC_RECORDER_HWACCEL_ARGS = "FFmpeg encoder hardware acceleration arguments."
164-
DESC_RECORDER_CODEC = "FFmpeg video encoder codec, eg <code>h264_nvenc</code>."
163+
DESC_RECORDER_CODEC = (
164+
"FFmpeg video encoder codec, eg <code>h264_nvenc</code>. "
165+
"Defaults to <code>copy</code> except for MJPEG streams where the default is "
166+
"<code>h264</code>."
167+
)
165168
DESC_RECORDER_AUDIO_CODEC = (
166169
"FFmpeg audio encoder codec, eg <code>aac</code>.<br>"
167170
"If your source has audio and you want to remove it, set this to <code>null</code>."

viseron/components/ffmpeg/stream.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from viseron.exceptions import FFprobeError, FFprobeTimeout, StreamInformationError
2020
from viseron.helpers import escape_string
2121
from viseron.helpers.logs import LogPipe, UnhelpfullLogFilter
22+
from viseron.helpers.validators import UNDEFINED
2223
from viseron.watchdog.subprocess_watchdog import RestartablePopen
2324

2425
from .const import (
@@ -313,6 +314,15 @@ def get_decoder_codec(
313314

314315
def get_encoder_codec(self) -> list[str]:
315316
"""Return encoder codec set in config."""
317+
if self._config[CONFIG_RECORDER][CONFIG_RECORDER_CODEC] == UNDEFINED:
318+
if self._config[CONFIG_STREAM_FORMAT] == "mjpeg":
319+
self._logger.warning(
320+
"MJPEG stream detected. Defaulting to h264 encoder codec. "
321+
"Consider setting `codec: h264` under the cameras `recorder` "
322+
"section in your config.yaml to avoid this warning. "
323+
)
324+
return ["-c:v", "h264"]
325+
return ["-c:v", "copy"]
316326
return ["-c:v", self._config[CONFIG_RECORDER][CONFIG_RECORDER_CODEC]]
317327

318328
def stream_command(
@@ -390,6 +400,36 @@ def recorder_audio_filter_args(self) -> list[str] | list:
390400
]
391401
return []
392402

403+
def _encoder_tuning_args(self) -> list[str]:
404+
"""Return encoder tuning args for MJPEG streams.
405+
406+
The libx264 encoder sometimes buffers frames when encoding MJPEG streams,
407+
causing a delay in the output.
408+
Using -tune zerolatency eliminates this buffering delay.
409+
"""
410+
if self._config[CONFIG_STREAM_FORMAT] == "mjpeg" and self._config[
411+
CONFIG_RECORDER
412+
][CONFIG_RECORDER_CODEC].lower() in [
413+
"libx264",
414+
"h264",
415+
]:
416+
return ["-tune", "zerolatency"]
417+
return []
418+
419+
def _force_keyframe_args(self) -> list[str]:
420+
"""Return force keyframe args for MJPEG streams.
421+
422+
The HLS muxer never triggers segment splits based on hls_time for some
423+
MJPEG streams. Force keyframes at the segment
424+
duration interval to ensure segments are created.
425+
"""
426+
if self._config[CONFIG_STREAM_FORMAT] == "mjpeg":
427+
return [
428+
"-force_key_frames",
429+
f"expr:gte(t,n_forced*{CAMERA_SEGMENT_DURATION})",
430+
]
431+
return []
432+
393433
def segment_args(self) -> list[str]:
394434
"""Generate FFmpeg segment args."""
395435
return (
@@ -413,6 +453,8 @@ def segment_args(self) -> list[str]:
413453
),
414454
]
415455
+ self.get_encoder_codec()
456+
+ self._encoder_tuning_args()
457+
+ self._force_keyframe_args()
416458
+ self.recorder_video_filter_args()
417459
+ self.get_encoder_audio_codec(self._mainstream.audio_codec)
418460
+ self.recorder_audio_filter_args()

0 commit comments

Comments
 (0)