Skip to content

Commit e86147f

Browse files
committed
[timecode] Use fractions for internal framerate representation
1 parent 0a8b68d commit e86147f

File tree

2 files changed

+87
-73
lines changed

2 files changed

+87
-73
lines changed

scenedetect/common.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@
9292
TimecodePair = ty.Tuple["FrameTimecode", "FrameTimecode"]
9393
"""Named type for pairs of timecodes, which typically represents the start/end of a scene."""
9494

95-
MAX_FPS_DELTA: float = 1.0 / 100000
96-
"""Maximum amount two framerates can differ by for equality testing."""
95+
MAX_FPS_DELTA: float = 1.0 / 1000000000.0
96+
"""Maximum amount two framerates can differ by for equality testing. Currently 1 frame/nanosec."""
9797

9898
_SECONDS_PER_MINUTE = 60.0
9999
_SECONDS_PER_HOUR = 60.0 * _SECONDS_PER_MINUTE
@@ -160,13 +160,14 @@ class FrameTimecode:
160160
def __init__(
161161
self,
162162
timecode: ty.Union[int, float, str, Timecode, "FrameTimecode"] = None,
163-
fps: ty.Union[int, float, str, "FrameTimecode"] = None,
163+
fps: ty.Union[float, "FrameTimecode", Fraction] = None,
164164
):
165165
"""
166166
Arguments:
167167
timecode: A frame number (`int`), number of seconds (`float`), timecode string in
168168
the form `'HH:MM:SS'` or `'HH:MM:SS.nnn'`, or a `Timecode`.
169-
fps: The framerate or FrameTimecode to use as a time base for all arithmetic.
169+
fps: The framerate to use for distance between frames and to calculate frame numbers.
170+
For a VFR video, this may just be the average framerate.
170171
Raises:
171172
TypeError: Thrown if either `timecode` or `fps` are unsupported types.
172173
ValueError: Thrown when specifying a negative timecode or framerate.
@@ -175,7 +176,7 @@ def __init__(
175176
# in a frame-specific manner. Note that once the framerate is set,
176177
# the value should never be modified (only read if required).
177178
# TODO(v1.0): Make these actual @properties.
178-
self._framerate = fps
179+
self._framerate: Fraction = None
179180
self._frame_num = None
180181
self._timecode: ty.Optional[Timecode] = None
181182
self._seconds: ty.Optional[float] = None
@@ -188,25 +189,31 @@ def __init__(
188189
self._seconds = timecode._seconds
189190
return
190191

191-
# Timecode.
192-
if isinstance(timecode, Timecode):
193-
self._timecode = timecode
194-
return
192+
if not isinstance(fps, (float, Fraction, FrameTimecode)):
193+
raise TypeError("fps must be of type float, Fraction, or FrameTimecode.")
195194

196195
# Ensure args are consistent with API.
197196
if fps is None:
198-
raise TypeError("Framerate (fps) is a required argument.")
197+
raise TypeError("fps is a required argument.")
199198
if isinstance(fps, FrameTimecode):
200-
fps = fps._framerate
201-
202-
# Process the given framerate, if it was not already set.
203-
if not isinstance(fps, (int, float)):
204-
raise TypeError("Framerate must be of type int/float.")
205-
if (isinstance(fps, int) and not fps > 0) or (
206-
isinstance(fps, float) and not fps >= MAX_FPS_DELTA
207-
):
208-
raise ValueError("Framerate must be positive and greater than zero.")
209-
self._framerate = float(fps)
199+
self._framerate = fps._framerate
200+
elif isinstance(fps, float):
201+
if fps <= MAX_FPS_DELTA:
202+
raise ValueError("Framerate must be positive and greater than zero.")
203+
self._framerate = Fraction.from_float(fps)
204+
elif isinstance(fps, Fraction):
205+
if float(fps) <= MAX_FPS_DELTA:
206+
raise ValueError("Framerate must be positive and greater than zero.")
207+
self._framerate = fps
208+
else:
209+
raise TypeError(
210+
f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode"
211+
)
212+
213+
# Timecode with a time base.
214+
if isinstance(timecode, Timecode):
215+
self._timecode = timecode
216+
return
210217
# Process the timecode value, storing it as an exact number of frames only if required.
211218
if isinstance(timecode, str) and timecode.isdigit():
212219
timecode = int(timecode)
@@ -243,7 +250,7 @@ def frame_num(self) -> ty.Optional[int]:
243250

244251
@property
245252
def framerate(self) -> ty.Optional[float]:
246-
return self._framerate
253+
return float(self._framerate)
247254

248255
def get_frames(self) -> int:
249256
"""[DEPRECATED] Get the current time/position in number of frames.

tests/test_timecode.py

Lines changed: 59 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
# Standard Library Imports
2727
from scenedetect.common import MAX_FPS_DELTA, FrameTimecode
28+
from fractions import Fraction
2829

2930

3031
def test_framerate():
@@ -38,11 +39,11 @@ def test_framerate():
3839
FrameTimecode(timecode=None, fps=FrameTimecode(timecode=0, fps=None))
3940
# Test zero FPS/negative.
4041
with pytest.raises(ValueError):
41-
FrameTimecode(timecode=0, fps=0)
42+
FrameTimecode(timecode=0, fps=0.0)
4243
with pytest.raises(ValueError):
43-
FrameTimecode(timecode=0, fps=-1)
44+
FrameTimecode(timecode=0, fps=-1.0)
4445
with pytest.raises(ValueError):
45-
FrameTimecode(timecode=0, fps=-100)
46+
FrameTimecode(timecode=0, fps=-100.0)
4647
with pytest.raises(ValueError):
4748
FrameTimecode(timecode=0, fps=0.0)
4849
with pytest.raises(ValueError):
@@ -52,26 +53,28 @@ def test_framerate():
5253
with pytest.raises(ValueError):
5354
FrameTimecode(timecode=0, fps=MAX_FPS_DELTA / 2)
5455
# Test positive framerates.
55-
assert FrameTimecode(timecode=0, fps=1).frame_num == 0
56-
assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0
57-
assert FrameTimecode(timecode=0, fps=10).frame_num == 0
56+
assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0
57+
assert FrameTimecode(timecode=0, fps=10.0).frame_num == 0
5858
assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA * 2).frame_num == 0
59-
assert FrameTimecode(timecode=0, fps=1000).frame_num == 0
6059
assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0
60+
assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0
61+
# Reject framerates too small for equality testing or potential divide by zero situations.
62+
with pytest.raises(ValueError):
63+
assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0
6164

6265

6366
def test_timecode_numeric():
6467
"""Test FrameTimecode constructor argument "timecode" with numeric arguments."""
6568
with pytest.raises(ValueError):
66-
FrameTimecode(timecode=-1, fps=1)
69+
FrameTimecode(timecode=-1, fps=1.0)
6770
with pytest.raises(ValueError):
6871
FrameTimecode(timecode=-1.0, fps=1.0)
6972
with pytest.raises(ValueError):
7073
FrameTimecode(timecode=-0.1, fps=1.0)
7174
with pytest.raises(ValueError):
7275
FrameTimecode(timecode=-1.0 / 1000, fps=1.0)
73-
assert FrameTimecode(timecode=0, fps=1).frame_num == 0
74-
assert FrameTimecode(timecode=1, fps=1).frame_num == 1
76+
assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0
77+
assert FrameTimecode(timecode=1, fps=1.0).frame_num == 1
7578
assert FrameTimecode(timecode=0.0, fps=1.0).frame_num == 0
7679
assert FrameTimecode(timecode=1.0, fps=1.0).frame_num == 1
7780

@@ -80,13 +83,13 @@ def test_timecode_string():
8083
"""Test FrameTimecode constructor argument "timecode" with string arguments."""
8184
# Invalid strings:
8285
with pytest.raises(ValueError):
83-
FrameTimecode(timecode="-1", fps=1)
86+
FrameTimecode(timecode="-1", fps=1.0)
8487
with pytest.raises(ValueError):
8588
FrameTimecode(timecode="-1.0", fps=1.0)
8689
with pytest.raises(ValueError):
8790
FrameTimecode(timecode="-0.1", fps=1.0)
8891
with pytest.raises(ValueError):
89-
FrameTimecode(timecode="1.9x", fps=1)
92+
FrameTimecode(timecode="1.9x", fps=1.0)
9093
with pytest.raises(ValueError):
9194
FrameTimecode(timecode="1x", fps=1.0)
9295
with pytest.raises(ValueError):
@@ -95,56 +98,56 @@ def test_timecode_string():
9598
FrameTimecode(timecode="1.0-", fps=1.0)
9699

97100
# Frame number integer [int->str] ('%d', integer number as string)
98-
assert FrameTimecode(timecode="0", fps=1).frame_num == 0
99-
assert FrameTimecode(timecode="1", fps=1).frame_num == 1
101+
assert FrameTimecode(timecode="0", fps=1.0).frame_num == 0
102+
assert FrameTimecode(timecode="1", fps=1.0).frame_num == 1
100103
assert FrameTimecode(timecode="10", fps=1.0).frame_num == 10
101104

102105
# Seconds format [float->str] ('%f', number as string)
103-
assert FrameTimecode(timecode="0.0", fps=1).frame_num == 0
104-
assert FrameTimecode(timecode="1.0", fps=1).frame_num == 1
106+
assert FrameTimecode(timecode="0.0", fps=1.0).frame_num == 0
107+
assert FrameTimecode(timecode="1.0", fps=1.0).frame_num == 1
105108
assert FrameTimecode(timecode="10.0", fps=1.0).frame_num == 10
106109
assert FrameTimecode(timecode="10.0000000000", fps=1.0).frame_num == 10
107110
assert FrameTimecode(timecode="10.100", fps=1.0).frame_num == 10
108111
assert FrameTimecode(timecode="1.100", fps=10.0).frame_num == 11
109112

110113
# Seconds format [float->str] ('%fs', number as string followed by 's' for seconds)
111-
assert FrameTimecode(timecode="0s", fps=1).frame_num == 0
112-
assert FrameTimecode(timecode="1s", fps=1).frame_num == 1
114+
assert FrameTimecode(timecode="0s", fps=1.0).frame_num == 0
115+
assert FrameTimecode(timecode="1s", fps=1.0).frame_num == 1
113116
assert FrameTimecode(timecode="10s", fps=1.0).frame_num == 10
114117
assert FrameTimecode(timecode="10.0s", fps=1.0).frame_num == 10
115118
assert FrameTimecode(timecode="10.0000000000s", fps=1.0).frame_num == 10
116119
assert FrameTimecode(timecode="10.100s", fps=1.0).frame_num == 10
117120
assert FrameTimecode(timecode="1.100s", fps=10.0).frame_num == 11
118121

119122
# Standard timecode format [timecode->str] ('HH:MM:SS[.nnn]', where [.nnn] is optional)
120-
assert FrameTimecode(timecode="00:00:01", fps=1).frame_num == 1
121-
assert FrameTimecode(timecode="00:00:01.9999", fps=1).frame_num == 2
122-
assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2
123-
assert FrameTimecode(timecode="00:00:02.0001", fps=1).frame_num == 2
123+
assert FrameTimecode(timecode="00:00:01", fps=1.0).frame_num == 1
124+
assert FrameTimecode(timecode="00:00:01.9999", fps=1.0).frame_num == 2
125+
assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2
126+
assert FrameTimecode(timecode="00:00:02.0001", fps=1.0).frame_num == 2
124127

125128
# MM:SS[.nnn] is also allowed
126-
assert FrameTimecode(timecode="00:01", fps=1).frame_num == 1
127-
assert FrameTimecode(timecode="00:01.9999", fps=1).frame_num == 2
128-
assert FrameTimecode(timecode="00:02.0000", fps=1).frame_num == 2
129-
assert FrameTimecode(timecode="00:02.0001", fps=1).frame_num == 2
129+
assert FrameTimecode(timecode="00:01", fps=1.0).frame_num == 1
130+
assert FrameTimecode(timecode="00:01.9999", fps=1.0).frame_num == 2
131+
assert FrameTimecode(timecode="00:02.0000", fps=1.0).frame_num == 2
132+
assert FrameTimecode(timecode="00:02.0001", fps=1.0).frame_num == 2
130133

131134
# Conversion edge cases
132-
assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10
133-
assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5
134-
assert FrameTimecode(timecode="00:00:00.100", fps=10).frame_num == 1
135-
assert FrameTimecode(timecode="00:00:00.001", fps=1000).frame_num == 1
135+
assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10
136+
assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5
137+
assert FrameTimecode(timecode="00:00:00.100", fps=10.0).frame_num == 1
138+
assert FrameTimecode(timecode="00:00:00.001", fps=1000.0).frame_num == 1
136139

137-
assert FrameTimecode(timecode="00:00:59.999", fps=1).frame_num == 60
138-
assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60
139-
assert FrameTimecode(timecode="00:01:00.001", fps=1).frame_num == 60
140+
assert FrameTimecode(timecode="00:00:59.999", fps=1.0).frame_num == 60
141+
assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60
142+
assert FrameTimecode(timecode="00:01:00.001", fps=1.0).frame_num == 60
140143

141-
assert FrameTimecode(timecode="00:59:59.999", fps=1).frame_num == 3600
142-
assert FrameTimecode(timecode="01:00:00.000", fps=1).frame_num == 3600
143-
assert FrameTimecode(timecode="01:00:00.001", fps=1).frame_num == 3600
144+
assert FrameTimecode(timecode="00:59:59.999", fps=1.0).frame_num == 3600
145+
assert FrameTimecode(timecode="01:00:00.000", fps=1.0).frame_num == 3600
146+
assert FrameTimecode(timecode="01:00:00.001", fps=1.0).frame_num == 3600
144147

145148
# Check too many ":" characters (https://github.com/Breakthrough/PySceneDetect/issues/476)
146149
with pytest.raises(ValueError):
147-
FrameTimecode(timecode="01:01:00:00.001", fps=1)
150+
FrameTimecode(timecode="01:01:00:00.001", fps=1.0)
148151

149152

150153
def test_get_frames():
@@ -157,10 +160,10 @@ def test_get_frames():
157160
assert FrameTimecode(timecode=1000.0, fps=60.0).frame_num == int(1000.0 * 60.0)
158161
assert FrameTimecode(timecode=1000000000.0, fps=29.97).frame_num == int(1000000000.0 * 29.97)
159162

160-
assert FrameTimecode(timecode="00:00:02.0000", fps=1).frame_num == 2
161-
assert FrameTimecode(timecode="00:00:00.5", fps=10).frame_num == 5
162-
assert FrameTimecode(timecode="00:00:01", fps=10).frame_num == 10
163-
assert FrameTimecode(timecode="00:01:00.000", fps=1).frame_num == 60
163+
assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2
164+
assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5
165+
assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10
166+
assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60
164167

165168

166169
def test_get_seconds():
@@ -173,10 +176,10 @@ def test_get_seconds():
173176
assert FrameTimecode(timecode=1000.0, fps=60.0).seconds, pytest.approx(1000.0)
174177
assert FrameTimecode(timecode=1000000000.0, fps=29.97).seconds, pytest.approx(1000000000.0)
175178

176-
assert FrameTimecode(timecode="00:00:02.0000", fps=1).seconds, pytest.approx(2.0)
177-
assert FrameTimecode(timecode="00:00:00.5", fps=10).seconds, pytest.approx(0.5)
178-
assert FrameTimecode(timecode="00:00:01", fps=10).seconds, pytest.approx(1.0)
179-
assert FrameTimecode(timecode="00:01:00.000", fps=1).seconds, pytest.approx(60.0)
179+
assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).seconds, pytest.approx(2.0)
180+
assert FrameTimecode(timecode="00:00:00.5", fps=10.0).seconds, pytest.approx(0.5)
181+
assert FrameTimecode(timecode="00:00:01", fps=10.0).seconds, pytest.approx(1.0)
182+
assert FrameTimecode(timecode="00:01:00.000", fps=1.0).seconds, pytest.approx(60.0)
180183

181184

182185
def test_get_timecode():
@@ -185,15 +188,15 @@ def test_get_timecode():
185188
assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == "00:01:00.117"
186189
assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == "01:00:00.234"
187190

188-
assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000"
189-
assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500"
191+
assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).get_timecode() == "00:00:02.000"
192+
assert FrameTimecode(timecode="00:00:00.5", fps=10.0).get_timecode() == "00:00:00.500"
190193
# If a value is provided in seconds, we store that value internally now.
191194
assert (
192-
FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=False)
195+
FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=False)
193196
== "00:00:01.501"
194197
)
195198
assert (
196-
FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=True)
199+
FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=True)
197200
== "00:00:01.500"
198201
)
199202

@@ -204,8 +207,10 @@ def test_equality():
204207
assert x == x
205208
assert x == FrameTimecode(timecode=1.0, fps=10.0)
206209
assert x == FrameTimecode(timecode=1.0, fps=10.0)
210+
assert x == FrameTimecode(timecode=1.0, fps=Fraction(10, 1))
207211
assert x != FrameTimecode(timecode=10.0, fps=10.0)
208212
assert x != FrameTimecode(timecode=10.0, fps=10.0)
213+
assert x != FrameTimecode(timecode=10.0, fps=Fraction(100, 10))
209214
assert x == FrameTimecode(x)
210215
assert x == FrameTimecode(1.0, x)
211216
assert x == FrameTimecode(10, x)
@@ -231,8 +236,8 @@ def test_equality():
231236
with pytest.raises(TypeError):
232237
assert x == {0: 0}
233238

234-
assert FrameTimecode(timecode="00:00:00.5", fps=10) == "00:00:00.500"
235-
assert FrameTimecode(timecode="00:00:01.500", fps=10) == "00:00:01.500"
239+
assert FrameTimecode(timecode="00:00:00.5", fps=10.0) == "00:00:00.500"
240+
assert FrameTimecode(timecode="00:00:01.500", fps=10.0) == "00:00:01.500"
236241

237242

238243
def test_addition():
@@ -261,7 +266,9 @@ def test_subtraction():
261266
assert FrameTimecode("00:00:00.000", fps=20.0) == x - 10
262267

263268

264-
@pytest.mark.parametrize("frame_num,fps", [(1, 1), (61, 14), (29, 25), (126, 24000 / 1001.0)])
269+
@pytest.mark.parametrize(
270+
"frame_num,fps", [(1, 1.0), (61, 14.0), (29, 25.0), (126, Fraction(24000, 1001))]
271+
)
265272
def test_identity(frame_num, fps):
266273
"""Test FrameTimecode values, when used in init return the same values"""
267274
frame_time_code = FrameTimecode(frame_num, fps=fps)

0 commit comments

Comments
 (0)