@@ -1566,12 +1566,31 @@ def test_fragmented_mp4(
15661566
15671567
15681568class TestStreamingEncoder :
1569- def test_double_close (self , tmp_path ):
1570- enc = StreamingEncoder (tmp_path / "test.mp4" )
1569+ @staticmethod
1570+ def _create_encoder (method , tmp_path , format ):
1571+ if method == "to_file" :
1572+ encoder_output = tmp_path / f"test.{ format } "
1573+ return StreamingEncoder (encoder_output ), encoder_output
1574+ elif method == "to_file_like" :
1575+ encoder_output = io .BytesIO ()
1576+ return StreamingEncoder (encoder_output , format = format ), encoder_output
1577+ else :
1578+ raise ValueError (f"Unknown method: { method } " )
1579+
1580+ @staticmethod
1581+ def _get_decoder_source (encoder_output ):
1582+ if isinstance (encoder_output , io .BytesIO ):
1583+ return encoder_output .getvalue ()
1584+ return str (encoder_output )
1585+
1586+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1587+ def test_double_close (self , tmp_path , method ):
1588+ enc , _ = self ._create_encoder (method , tmp_path , "mp4" )
15711589 enc .close ()
15721590 enc .close () # double close is a no-op
15731591
15741592 @pytest .mark .parametrize ("format" , ["mp4" , "mov" , "mkv" ])
1593+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
15751594 @pytest .mark .parametrize (
15761595 "device" ,
15771596 (
@@ -1587,16 +1606,15 @@ def test_double_close(self, tmp_path):
15871606 ),
15881607 ),
15891608 )
1590- def test_add_video_and_encode_frames (self , tmp_path , format , device ):
1609+ def test_add_video_and_encode_frames (self , tmp_path , format , method , device ):
15911610 source_decoder = VideoDecoder (str (TEST_SRC_2_720P .path ))
15921611 source_frames = source_decoder .get_frames_in_range (start = 0 , stop = 10 ).data .to (
15931612 device
15941613 )
15951614 frame_rate = source_decoder .metadata .average_fps
15961615 percentage , atol = (96 , 2 ) if device == "cuda" else (99 , 2 )
15971616
1598- output_path = tmp_path / f"test.{ format } "
1599- enc = StreamingEncoder (output_path )
1617+ enc , encoder_output = self ._create_encoder (method , tmp_path , format )
16001618 add_video_kwargs = {
16011619 "height" : source_frames .shape [2 ],
16021620 "width" : source_frames .shape [3 ],
@@ -1615,7 +1633,9 @@ def test_add_video_and_encode_frames(self, tmp_path, format, device):
16151633 enc .close ()
16161634
16171635 decoded_frames = (
1618- VideoDecoder (str (output_path )).get_frames_in_range (start = 0 , stop = 10 ).data
1636+ VideoDecoder (self ._get_decoder_source (encoder_output ))
1637+ .get_frames_in_range (start = 0 , stop = 10 )
1638+ .data
16191639 )
16201640 assert_tensor_close_on_at_least (
16211641 decoded_frames , source_frames .cpu (), percentage = percentage , atol = atol
@@ -1625,11 +1645,20 @@ def test_create_invalid_path(self):
16251645 with pytest .raises (RuntimeError , match = "make sure it's a valid path" ):
16261646 StreamingEncoder ("/nonexistent/dir/test.mp4" )
16271647
1628- def test_create_invalid_format (self , tmp_path ):
1629- with pytest .raises (RuntimeError , match = "check the desired extension" ):
1630- StreamingEncoder (tmp_path / "test.bad_extension" )
1648+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1649+ def test_create_invalid_format (self , tmp_path , method ):
1650+ if method == "to_file" :
1651+ with pytest .raises (RuntimeError , match = "check the desired extension" ):
1652+ StreamingEncoder (tmp_path / "test.bad_extension" )
1653+ elif method == "to_file_like" :
1654+ with pytest .raises (
1655+ RuntimeError ,
1656+ match = r"Check the desired format\? Got format=bad_extension" ,
1657+ ):
1658+ StreamingEncoder (io .BytesIO (), format = "bad_extension" )
16311659
16321660 @pytest .mark .parametrize ("format" , ["mp4" , "mov" ])
1661+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
16331662 @pytest .mark .parametrize (
16341663 "device" ,
16351664 (
@@ -1645,16 +1674,20 @@ def test_create_invalid_format(self, tmp_path):
16451674 ),
16461675 ),
16471676 )
1648- def test_fragmented_mp4 (self , format , tmp_path , device ):
1677+ def test_fragmented_mp4 (self , format , tmp_path , method , device ):
16491678 source_decoder = VideoDecoder (str (TEST_SRC_2_720P .path ))
16501679 source_frames = source_decoder .get_frames_in_range (start = 0 , stop = 10 ).data .to (
16511680 device
16521681 )
16531682 frame_rate = source_decoder .metadata .average_fps
16541683 percentage , atol = (96 , 2 ) if device == "cuda" else (99 , 2 )
16551684
1656- output_path = tmp_path / f"test.{ format } "
1657- enc = StreamingEncoder (output_path )
1685+ enc , encoder_output = self ._create_encoder (method , tmp_path , format )
1686+ # In addition to the fragmentation flag, "flush_packets" and "threads"
1687+ # are necessary to decode frames before close().
1688+ # See frag flags: https://ffmpeg.org/ffmpeg-formats.html#Fragmentation
1689+ # TODO MultiStreamEncoder: Get a better understanding of which options
1690+ # are necessary for reading fragmented mp4s
16581691 extra_options = {
16591692 "movflags" : "+frag_every_frame+empty_moov" ,
16601693 "flush_packets" : "1" ,
@@ -1676,9 +1709,10 @@ def test_fragmented_mp4(self, format, tmp_path, device):
16761709 extra_options = extra_options ,
16771710 )
16781711 enc .open ()
1712+ # Here, we decode the available fragmented mp4 frames before calling close()
16791713 for batch in [source_frames [:5 ], source_frames [5 :]]:
16801714 video .write (batch )
1681- mid_decoder = VideoDecoder (str ( output_path ))
1715+ mid_decoder = VideoDecoder (self . _get_decoder_source ( encoder_output ))
16821716 num_available = len (mid_decoder )
16831717 assert num_available > 0
16841718 assert_tensor_close_on_at_least (
@@ -1689,25 +1723,31 @@ def test_fragmented_mp4(self, format, tmp_path, device):
16891723 )
16901724
16911725 enc .close ()
1726+ # After close, all frames must be decodable
16921727 assert_tensor_close_on_at_least (
1693- VideoDecoder (str (output_path )).get_frames_in_range (start = 0 , stop = 10 ).data ,
1728+ VideoDecoder (self ._get_decoder_source (encoder_output ))
1729+ .get_frames_in_range (start = 0 , stop = 10 )
1730+ .data ,
16941731 source_frames .cpu (),
16951732 percentage = percentage ,
16961733 atol = atol ,
16971734 )
16981735
1699- def test_add_video_twice_errors (self , tmp_path ):
1700- enc = StreamingEncoder (tmp_path / "test.mp4" )
1736+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1737+ def test_add_video_twice_errors (self , tmp_path , method ):
1738+ enc , _ = self ._create_encoder (method , tmp_path , "mp4" )
17011739 enc .add_video (height = 64 , width = 64 , frame_rate = 30.0 )
17021740 with pytest .raises (RuntimeError , match = "already been added" ):
17031741 enc .add_video (height = 64 , width = 64 , frame_rate = 24.0 )
17041742
1705- def test_add_audio_twice_errors (self , tmp_path ):
1706- enc = StreamingEncoder (tmp_path / "test.mp4" )
1743+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1744+ def test_add_audio_twice_errors (self , tmp_path , method ):
1745+ enc , _ = self ._create_encoder (method , tmp_path , "mp4" )
17071746 enc .add_audio (sample_rate = 44100 , num_channels = 2 )
17081747 with pytest .raises (RuntimeError , match = "already been added" ):
17091748 enc .add_audio (sample_rate = 16000 , num_channels = 1 )
17101749
1750+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
17111751 @pytest .mark .parametrize (
17121752 "device" ,
17131753 (
@@ -1723,15 +1763,17 @@ def test_add_audio_twice_errors(self, tmp_path):
17231763 ),
17241764 ),
17251765 )
1726- def test_write_frames_mismatched_dimensions_errors (self , tmp_path , device ):
1727- enc = StreamingEncoder ( tmp_path / "test. mp4" )
1766+ def test_write_frames_mismatched_dimensions_errors (self , tmp_path , method , device ):
1767+ enc , _ = self . _create_encoder ( method , tmp_path , " mp4" )
17281768 video = enc .add_video (height = 256 , width = 256 , frame_rate = 30.0 , device = device )
17291769 enc .open ()
1770+ # write with wrong size errors
17301771 frames_128 = torch .randint (
17311772 0 , 256 , (2 , 3 , 128 , 128 ), dtype = torch .uint8 , device = device
17321773 )
17331774 with pytest .raises (RuntimeError , match = "same dimensions" ):
17341775 video .write (frames_128 )
1776+ # write with different size than first also errors
17351777 frames_256 = torch .randint (
17361778 0 , 256 , (2 , 3 , 256 , 256 ), dtype = torch .uint8 , device = device
17371779 )
@@ -1743,8 +1785,9 @@ def test_write_frames_mismatched_dimensions_errors(self, tmp_path, device):
17431785 video .write (frames_512 )
17441786
17451787 @pytest .mark .needs_cuda
1746- def test_write_frames_different_devices_errors (self , tmp_path ):
1747- enc = StreamingEncoder (tmp_path / "test.mp4" )
1788+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1789+ def test_write_frames_different_devices_errors (self , tmp_path , method ):
1790+ enc , _ = self ._create_encoder (method , tmp_path , "mp4" )
17481791 video = enc .add_video (height = 64 , width = 64 , frame_rate = 30.0 )
17491792 enc .open ()
17501793 cpu_frames = torch .randint (0 , 256 , (2 , 3 , 64 , 64 ), dtype = torch .uint8 )
@@ -1754,72 +1797,79 @@ def test_write_frames_different_devices_errors(self, tmp_path):
17541797 video .write (cuda_frames )
17551798
17561799 @pytest .mark .needs_cuda
1757- def test_write_samples_on_cuda_errors (self , tmp_path ):
1758- enc = StreamingEncoder (tmp_path / "test.wav" )
1800+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1801+ def test_write_samples_on_cuda_errors (self , tmp_path , method ):
1802+ enc , _ = self ._create_encoder (method , tmp_path , "wav" )
17591803 audio = enc .add_audio (sample_rate = 44100 , num_channels = 1 )
17601804 enc .open ()
17611805 cuda_samples = torch .randn (1 , 1000 , device = "cuda" )
17621806 with pytest .raises (RuntimeError , match = "samples must be on CPU, got cuda" ):
17631807 audio .write (cuda_samples )
17641808
1809+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
17651810 @pytest .mark .parametrize (
17661811 "device" , ("cpu" , pytest .param ("cuda" , marks = pytest .mark .needs_cuda ))
17671812 )
1768- def test_write_frames_without_open_errors (self , tmp_path , device ):
1769- enc = StreamingEncoder ( tmp_path / "test. mp4" )
1813+ def test_write_frames_without_open_errors (self , tmp_path , method , device ):
1814+ enc , _ = self . _create_encoder ( method , tmp_path , " mp4" )
17701815 video = enc .add_video (height = 64 , width = 64 , frame_rate = 30.0 , device = device )
17711816 frames = torch .randint (0 , 256 , (5 , 3 , 64 , 64 ), dtype = torch .uint8 , device = device )
17721817 with pytest .raises (
17731818 RuntimeError , match = "Call open\\ (\\ ) before addFrames\\ (\\ )"
17741819 ):
17751820 video .write (frames )
17761821
1777- def test_write_samples_without_open_errors (self , tmp_path ):
1778- enc = StreamingEncoder (tmp_path / "test.mp4" )
1822+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1823+ def test_write_samples_without_open_errors (self , tmp_path , method ):
1824+ enc , _ = self ._create_encoder (method , tmp_path , "mp4" )
17791825 audio = enc .add_audio (sample_rate = 44100 , num_channels = 1 )
17801826 samples = torch .randn (1 , 1000 )
17811827 with pytest .raises (
17821828 RuntimeError , match = "Call open\\ (\\ ) before addSamples\\ (\\ )"
17831829 ):
17841830 audio .write (samples )
17851831
1786- def test_write_samples_mismatched_channels_errors (self , tmp_path ):
1787- enc = StreamingEncoder (tmp_path / "test.wav" )
1832+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1833+ def test_write_samples_mismatched_channels_errors (self , tmp_path , method ):
1834+ enc , _ = self ._create_encoder (method , tmp_path , "wav" )
17881835 audio = enc .add_audio (sample_rate = 44100 , num_channels = 1 )
17891836 enc .open ()
17901837 samples = torch .randn (2 , 1000 ) # 2 channels but stream expects 1
17911838 with pytest .raises (RuntimeError , match = "Expected 1 channels, got 2" ):
17921839 audio .write (samples )
17931840
1794- def test_open_without_stream_errors (self , tmp_path ):
1795- enc = StreamingEncoder (tmp_path / "test.mp4" )
1841+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1842+ def test_open_without_stream_errors (self , tmp_path , method ):
1843+ enc , _ = self ._create_encoder (method , tmp_path , "mp4" )
17961844 with pytest .raises (
17971845 RuntimeError ,
17981846 match = "Call addVideoStream\\ (\\ ) or addAudioStream\\ (\\ ) before open\\ (\\ )" ,
17991847 ):
18001848 enc .open ()
18011849
1802- def test_open_twice_errors (self , tmp_path ):
1803- enc = StreamingEncoder (tmp_path / "test.mp4" )
1850+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1851+ def test_open_twice_errors (self , tmp_path , method ):
1852+ enc , _ = self ._create_encoder (method , tmp_path , "mp4" )
18041853 enc .add_video (height = 64 , width = 64 , frame_rate = 30.0 )
18051854 enc .open ()
18061855 with pytest .raises (RuntimeError , match = "open\\ (\\ ) was already called" ):
18071856 enc .open ()
18081857
1809- def test_add_audio_invalid_bit_rate_errors (self , tmp_path ):
1810- enc = StreamingEncoder (tmp_path / "test.mp4" )
1858+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1859+ def test_add_audio_invalid_bit_rate_errors (self , tmp_path , method ):
1860+ enc , _ = self ._create_encoder (method , tmp_path , "mp4" )
18111861 enc .add_audio (sample_rate = 44100 , num_channels = 2 , bit_rate = - 1 )
18121862 with pytest .raises (RuntimeError , match = "bit_rate=-1 must be >= 0" ):
18131863 enc .open ()
18141864
1815- def test_add_audio_and_encode_samples (self , tmp_path ):
1865+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1866+ def test_add_audio_and_encode_samples (self , tmp_path , method ):
18161867 source_audio = AudioDecoder (str (SINE_MONO_S32 .path )).get_all_samples ()
18171868 samples = source_audio .data
18181869 sample_rate = source_audio .sample_rate
18191870 num_channels = samples .shape [0 ]
18201871
1821- output_path = tmp_path / "test.wav"
1822- enc = StreamingEncoder (output_path )
1872+ enc , encoder_output = self ._create_encoder (method , tmp_path , "wav" )
18231873 audio = enc .add_audio (sample_rate = sample_rate , num_channels = num_channels )
18241874 enc .open ()
18251875 chunk_lengths = [1 , 50 , 1000 , 0 , 25 ]
@@ -1830,7 +1880,9 @@ def test_add_audio_and_encode_samples(self, tmp_path):
18301880 audio .write (samples [:, offset :])
18311881 enc .close ()
18321882
1833- decoded = AudioDecoder (str (output_path )).get_all_samples ()
1883+ decoded = AudioDecoder (
1884+ self ._get_decoder_source (encoder_output )
1885+ ).get_all_samples ()
18341886 assert decoded .data .shape [0 ] == num_channels
18351887 assert decoded .sample_rate == sample_rate
18361888 torch .testing .assert_close (decoded .data , samples , atol = 1e-4 , rtol = 0 )
@@ -1849,7 +1901,8 @@ def test_add_audio_and_encode_samples(self, tmp_path):
18491901 "mov" ,
18501902 ),
18511903 )
1852- def test_add_audio_and_video_and_encode (self , tmp_path , format ):
1904+ @pytest .mark .parametrize ("method" , ("to_file" , "to_file_like" ))
1905+ def test_add_audio_and_video_and_encode (self , tmp_path , format , method ):
18531906 source_video_decoder = VideoDecoder (str (NASA_VIDEO .path ))
18541907 source_frames = source_video_decoder .get_frames_in_range (
18551908 start = 0 , stop = len (source_video_decoder )
@@ -1859,8 +1912,7 @@ def test_add_audio_and_video_and_encode(self, tmp_path, format):
18591912 source_samples = source_audio .data
18601913 sample_rate = source_audio .sample_rate
18611914
1862- output_path = tmp_path / f"test.{ format } "
1863- enc = StreamingEncoder (output_path )
1915+ enc , encoder_output = self ._create_encoder (method , tmp_path , format )
18641916 video = enc .add_video (
18651917 height = source_frames .shape [2 ],
18661918 width = source_frames .shape [3 ],
@@ -1881,18 +1933,24 @@ def test_add_audio_and_video_and_encode(self, tmp_path, format):
18811933 audio .write (source_samples [:, half_samples :])
18821934 enc .close ()
18831935
1884- decoded_video_decoder = VideoDecoder (str (output_path ))
1936+ source = self ._get_decoder_source (encoder_output )
1937+
1938+ decoded_video_decoder = VideoDecoder (source )
18851939 decoded_frames = decoded_video_decoder .get_frames_in_range (
18861940 start = 0 , stop = len (decoded_video_decoder )
18871941 ).data
18881942 assert_tensor_close_on_at_least (
18891943 decoded_frames , source_frames , percentage = 99 , atol = 2
18901944 )
18911945
1892- audio_decoder = AudioDecoder (str ( output_path ) )
1946+ audio_decoder = AudioDecoder (source )
18931947 decoded_audio = audio_decoder .get_all_samples ()
18941948 assert decoded_audio .sample_rate == sample_rate
18951949 assert decoded_audio .data .shape [0 ] == source_samples .shape [0 ]
1950+ # Codecs for lossy audio formats (not WAV or FLAC) can add padding which causes
1951+ # sample count to differ, so we only compare the smaller sample count.
1952+ # TODO MultiStreamEncoder: The previous AudioEncoder didn't need
1953+ # padding after introducing a FIFO. Investigate why this is needed.
18961954 num_samples_to_compare = min (
18971955 decoded_audio .data .shape [1 ], source_samples .shape [1 ]
18981956 )
0 commit comments