Skip to content

Commit f9c75ec

Browse files
authored
Store original data ref in reference audio/video frames (#1176)
Previously, `create_reference_audio_frame` and `create_reference_video_frame` created views to the underlying buffer without keeping a reference to the original data. This was dangerous because if the original array was garbage collected while the frame object still existed, it would lead to undefined behavior and potential segmentation faults. This commit resolves this by: - Adding dynamic attributes to Frame classes: Modified the nanobind class registration for `AudioFrames` and `VideoFrames` in `frames.cpp` to include `nb::dynamic_attr()`, which allows Python to add custom attributes to these C++ objects. - Storing reference to original data: Updated the Python implementations of `create_reference_audio_frame` and `create_reference_video_frame` to store the original array in `frame._array_ref` after creating the frame object. This prevents the original data from being garbage collected as long as the frame object is alive.
1 parent 5466248 commit f9c75ec

3 files changed

Lines changed: 184 additions & 12 deletions

File tree

src/spdl/io/_core.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,12 +1143,12 @@ def create_reference_audio_frame(
11431143
This function should be used when the media data processed in Python should
11441144
be further processed by filter graph, and/or encoded.
11451145
1146-
.. attention::
1146+
.. note::
11471147
11481148
The resulting frame object references the memory region owned by the input
1149-
array, but it does not own the reference to the original array.
1150-
1151-
Make sure that the array object is alive until the frame object is consumed.
1149+
array and keeps a reference to the original array to prevent it from being
1150+
garbage collected. The original data will remain alive as long as the frame
1151+
object is alive.
11521152
11531153
Args:
11541154
array: 2D array or tensor.
@@ -1177,12 +1177,14 @@ def create_reference_audio_frame(
11771177
Returns:
11781178
Frames object that references the memory region of the input data.
11791179
"""
1180-
return _libspdl.create_reference_audio_frame(
1180+
frame = _libspdl.create_reference_audio_frame(
11811181
array=array,
11821182
sample_fmt=sample_fmt,
11831183
sample_rate=sample_rate,
11841184
pts=pts,
11851185
)
1186+
frame._array_ref = array
1187+
return frame
11861188

11871189

11881190
def create_reference_video_frame(
@@ -1193,12 +1195,12 @@ def create_reference_video_frame(
11931195
This function should be used when the media data processed in Python should
11941196
be further processed by filter graph, and/or encoded.
11951197
1196-
.. attention::
1198+
.. note::
11971199
11981200
The resulting frame object references the memory region owned by the input
1199-
array, but it does not own the reference to the original array.
1200-
1201-
Make sure that the array object is alive until the frame object is consumed.
1201+
array and keeps a reference to the original array to prevent it from being
1202+
garbage collected. The original data will remain alive as long as the frame
1203+
object is alive.
12021204
12031205
Args:
12041206
array: 3D or 4D array or tensor.
@@ -1221,12 +1223,14 @@ def create_reference_video_frame(
12211223
Returns:
12221224
Frames object that references the memory region of the input data.
12231225
"""
1224-
return _libspdl.create_reference_video_frame(
1226+
frame = _libspdl.create_reference_video_frame(
12251227
array=array,
12261228
pix_fmt=pix_fmt,
12271229
frame_rate=frame_rate,
12281230
pts=pts,
12291231
)
1232+
frame._array_ref = array
1233+
return frame
12301234

12311235

12321236
################################################################################

src/spdl/io/lib/core/frames.cpp

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ void register_frames(nb::module_& m) {
6666
m,
6767
"AudioFrames",
6868
"Audio frames.\n\n"
69-
"See :doc:`/io/packets_frames_concepts` for information about the Frames base concept.")
69+
"See :doc:`/io/packets_frames_concepts` for information about the Frames base concept.",
70+
nb::dynamic_attr())
7071
.def_prop_ro(
7172
"num_frames",
7273
[](AudioFrames& self) {
@@ -133,7 +134,8 @@ void register_frames(nb::module_& m) {
133134
m,
134135
"VideoFrames",
135136
"Video frames.\n\n"
136-
"See :doc:`/io/packets_frames_concepts` for information about the Frames base concept.")
137+
"See :doc:`/io/packets_frames_concepts` for information about the Frames base concept.",
138+
nb::dynamic_attr())
137139
.def_prop_ro(
138140
"num_frames",
139141
[](VideoFrames& self) {

tests/io/reference_frames_test.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# pyre-unsafe
8+
9+
import gc
10+
import unittest
11+
import weakref
12+
13+
import numpy as np
14+
import spdl.io
15+
16+
17+
class TestReferenceAudioFrame(unittest.TestCase):
18+
def test_audio_frame_keeps_reference_alive(self) -> None:
19+
"""Test that AudioFrame keeps the original array alive even after deletion."""
20+
# Setup: Create an array and a weak reference to it
21+
array = np.random.randint(0, 255, size=(100, 2), dtype=np.uint8)
22+
weak_ref = weakref.ref(array)
23+
24+
# Execute: Create a reference frame
25+
frame = spdl.io.create_reference_audio_frame(
26+
array=array,
27+
sample_fmt="u8",
28+
sample_rate=44100,
29+
pts=0,
30+
)
31+
32+
# Assert: The array should still be alive (referenced by both array and frame)
33+
self.assertIsNotNone(weak_ref())
34+
35+
# Execute: Delete the original array variable
36+
del array
37+
gc.collect()
38+
39+
# Assert: The array should still be alive (referenced by frame)
40+
self.assertIsNotNone(weak_ref())
41+
42+
# Execute: Delete the frame
43+
del frame
44+
gc.collect()
45+
46+
# Assert: Now the array should be garbage collected
47+
self.assertIsNone(weak_ref())
48+
49+
def test_audio_frame_can_be_converted(self) -> None:
50+
"""Test that reference audio frame can be converted to buffer."""
51+
array = np.random.randint(0, 255, size=(100, 2), dtype=np.uint8)
52+
53+
frame = spdl.io.create_reference_audio_frame(
54+
array=array,
55+
sample_fmt="u8",
56+
sample_rate=44100,
57+
pts=0,
58+
)
59+
buffer = spdl.io.convert_frames(frame)
60+
61+
buffer_array = spdl.io.to_numpy(buffer)
62+
self.assertEqual(buffer_array.shape, (100, 2))
63+
np.testing.assert_array_equal(buffer_array, array)
64+
65+
def test_audio_frame_planar_format(self) -> None:
66+
"""Test that planar format audio frames work correctly."""
67+
# Setup: Create planar format data (channels first)
68+
array = np.random.randint(0, 255, size=(2, 100), dtype=np.uint8)
69+
70+
# Execute: Create reference frame with planar format
71+
frame = spdl.io.create_reference_audio_frame(
72+
array=array,
73+
sample_fmt="u8p",
74+
sample_rate=44100,
75+
pts=0,
76+
)
77+
78+
# Assert: Frame should have correct properties
79+
self.assertEqual(frame.num_frames, 100)
80+
self.assertEqual(frame.num_channels, 2)
81+
self.assertEqual(frame.sample_rate, 44100)
82+
83+
84+
class TestReferenceVideoFrame(unittest.TestCase):
85+
def test_video_frame_keeps_reference_alive(self) -> None:
86+
"""Test that VideoFrame keeps the original array alive even after deletion."""
87+
# Setup: Create an array and a weak reference to it
88+
array = np.random.randint(0, 255, size=(5, 128, 128, 3), dtype=np.uint8)
89+
weak_ref = weakref.ref(array)
90+
91+
# Execute: Create a reference frame
92+
frame = spdl.io.create_reference_video_frame(
93+
array=array,
94+
pix_fmt="rgb24",
95+
frame_rate=(30, 1),
96+
pts=0,
97+
)
98+
99+
# Assert: The array should still be alive (referenced by both array and frame)
100+
self.assertIsNotNone(weak_ref())
101+
102+
# Execute: Delete the original array variable
103+
del array
104+
gc.collect()
105+
106+
# Assert: The array should still be alive (referenced by frame)
107+
self.assertIsNotNone(weak_ref())
108+
109+
# Execute: Delete the frame
110+
del frame
111+
gc.collect()
112+
113+
# Assert: Now the array should be garbage collected
114+
self.assertIsNone(weak_ref())
115+
116+
def test_video_frame_can_be_converted(self) -> None:
117+
"""Test that reference video frame can be converted to buffer."""
118+
array = np.random.randint(0, 255, size=(5, 128, 128, 3), dtype=np.uint8)
119+
120+
frame = spdl.io.create_reference_video_frame(
121+
array=array,
122+
pix_fmt="rgb24",
123+
frame_rate=(30, 1),
124+
pts=0,
125+
)
126+
buffer = spdl.io.convert_frames(frame)
127+
128+
buffer_array = spdl.io.to_numpy(buffer)
129+
self.assertEqual(buffer_array.shape, (5, 128, 128, 3))
130+
np.testing.assert_array_equal(buffer_array, array)
131+
132+
def test_video_frame_grayscale(self) -> None:
133+
"""Test that grayscale video frames work correctly."""
134+
# Setup: Create grayscale data
135+
array = np.random.randint(0, 255, size=(5, 128, 128), dtype=np.uint8)
136+
137+
# Execute: Create reference frame with grayscale format
138+
frame = spdl.io.create_reference_video_frame(
139+
array=array,
140+
pix_fmt="gray8",
141+
frame_rate=(30, 1),
142+
pts=0,
143+
)
144+
145+
# Assert: Frame should have correct properties
146+
self.assertEqual(frame.num_frames, 5)
147+
self.assertEqual(frame.width, 128)
148+
self.assertEqual(frame.height, 128)
149+
150+
def test_video_frame_yuv_format(self) -> None:
151+
"""Test that YUV planar format video frames work correctly."""
152+
# Setup: Create YUV planar format data (channels first)
153+
array = np.random.randint(0, 255, size=(5, 3, 128, 128), dtype=np.uint8)
154+
155+
# Execute: Create reference frame with YUV planar format
156+
frame = spdl.io.create_reference_video_frame(
157+
array=array,
158+
pix_fmt="yuv444p",
159+
frame_rate=(30, 1),
160+
pts=0,
161+
)
162+
163+
# Assert: Frame should have correct properties
164+
self.assertEqual(frame.num_frames, 5)
165+
self.assertEqual(frame.width, 128)
166+
self.assertEqual(frame.height, 128)

0 commit comments

Comments
 (0)