Skip to content

Commit 6c1e9d7

Browse files
committed
Add file-like to Python and tests
1 parent 9656716 commit 6c1e9d7

2 files changed

Lines changed: 110 additions & 48 deletions

File tree

src/torchcodec/encoders/_multi_stream_encoder.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from pathlib import Path
21
from typing import Any
32

43
from torch import Tensor
@@ -23,8 +22,13 @@ def write(self, samples: Tensor) -> None:
2322

2423

2524
class StreamingEncoder:
26-
def __init__(self, dest: str | Path):
27-
self._encoder_tensor = _core.create_streaming_encoder_to_file(str(dest))
25+
def __init__(self, dest, *, format: str | None = None):
26+
if format is not None:
27+
self._encoder_tensor = _core.create_streaming_encoder_to_file_like(
28+
format, dest
29+
)
30+
else:
31+
self._encoder_tensor = _core.create_streaming_encoder_to_file(str(dest))
2832

2933
def add_video(
3034
self,

test/test_encoders.py

Lines changed: 103 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,12 +1566,31 @@ def test_fragmented_mp4(
15661566

15671567

15681568
class 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

Comments
 (0)