11"""FFmpeg stream tests."""
2+
23from __future__ import annotations
34
45from contextlib import nullcontext
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 ,
4042)
4143from viseron .components .ffmpeg .stream import FFprobe , Stream
4244from viseron .const import (
45+ CAMERA_SEGMENT_DURATION ,
4346 ENV_CUDA_SUPPORTED ,
4447 ENV_JETSON_NANO ,
4548 ENV_RASPBERRYPI3 ,
4649 ENV_RASPBERRYPI4 ,
4750)
4851from viseron .exceptions import StreamInformationError
52+ from viseron .helpers .validators import UNDEFINED
4953
5054from tests .common import MockCamera
5155
7175
7276CONFIG_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
0 commit comments