{{ message }}
Repository navigation
Expand file tree
/
Copy pathtest_timecode.py
More file actions
686 lines (562 loc) · 30.9 KB
/
Copy pathtest_timecode.py
File metadata and controls
686 lines (562 loc) · 30.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
#
# PySceneDetect: Python-Based Video Scene Detector
# -------------------------------------------------------------------
# [ Site: https://scenedetect.com ]
# [ Docs: https://scenedetect.com/docs/ ]
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
#
# Copyright (C) 2025 Brandon Castellano <http://www.bcastell.com>.
# PySceneDetect is licensed under the BSD 3-Clause License; see the
# included LICENSE file, or visit one of the above pages for details.
#
"""PySceneDetect scenedetect.timecode Tests
This file includes unit tests for the scenedetect.timecode module (specifically, the
FrameTimecode object, used for representing frame-accurate timestamps and time values).
These unit tests test the FrameTimecode object with respect to object construction,
testing argument format/limits, operators (addition/subtraction), and conversion
to and from various time formats like integer frame number, float number of seconds,
or string HH:MM:SS[.nnn]. timecode format.
"""
# Third-Party Library Imports
import numbers
from fractions import Fraction
import pytest
# Standard Library Imports
from scenedetect.common import MAX_FPS_DELTA, FrameTimecode, Timecode, framerate_to_fraction
from scenedetect.detector import FlashFilter
def test_framerate():
"""Test FrameTimecode constructor argument "fps"."""
# Not passing fps results in TypeError.
with pytest.raises(TypeError):
FrameTimecode() # type: ignore[call-arg]
with pytest.raises(TypeError):
FrameTimecode(timecode=0, fps=None)
with pytest.raises(TypeError):
FrameTimecode(
timecode=None, # type: ignore[arg-type]
fps=FrameTimecode(timecode=0, fps=None),
)
# Test zero FPS/negative.
with pytest.raises(ValueError):
FrameTimecode(timecode=0, fps=0.0)
with pytest.raises(ValueError):
FrameTimecode(timecode=0, fps=-1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode=0, fps=-100.0)
with pytest.raises(ValueError):
FrameTimecode(timecode=0, fps=0.0)
with pytest.raises(ValueError):
FrameTimecode(timecode=0, fps=-1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode=0, fps=-1000.0)
with pytest.raises(ValueError):
FrameTimecode(timecode=0, fps=MAX_FPS_DELTA / 2)
# Test positive framerates.
assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0
assert FrameTimecode(timecode=0, fps=10.0).frame_num == 0
assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA * 2).frame_num == 0
assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0
assert FrameTimecode(timecode=0, fps=1000.0).frame_num == 0
# Reject framerates too small for equality testing or potential divide by zero situations.
with pytest.raises(ValueError):
assert FrameTimecode(timecode=0, fps=MAX_FPS_DELTA).frame_num == 0
def test_frame_rate_property():
"""`frame_rate` returns an exact Fraction; `framerate` returns the float equivalent."""
# Integer rate.
tc = FrameTimecode(timecode=0, fps=30.0)
assert tc.frame_rate == Fraction(30, 1)
assert isinstance(tc.frame_rate, Fraction)
with pytest.warns(DeprecationWarning, match="frame_rate"):
legacy_frame_rate = tc.framerate
assert legacy_frame_rate == 30.0
assert isinstance(legacy_frame_rate, float)
# Constructed directly from a Fraction (the exact form for NTSC rates).
tc = FrameTimecode(timecode=0, fps=Fraction(30000, 1001))
assert tc.frame_rate == Fraction(30000, 1001)
with pytest.warns(DeprecationWarning, match="frame_rate"):
assert tc.framerate == pytest.approx(float(Fraction(30000, 1001)))
tc = FrameTimecode(timecode=0, fps=Fraction(24000, 1001))
assert tc.frame_rate == Fraction(24000, 1001)
# time_base equals 1 / frame_rate for CFR sources.
assert tc.frame_rate is not None
assert tc.time_base == 1 / tc.frame_rate
def test_frame_rate_for_vfr():
"""For Timecode-backed instances, frame_rate is the approximation passed via fps."""
fps = Fraction(24000, 1001)
tc = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps)
# frame_rate exposes the rate carried by the FrameTimecode (an approximation for VFR).
assert tc.frame_rate == fps
# time_base is authoritative for VFR and need not equal 1 / frame_rate.
assert tc.time_base == Fraction(1, 24000)
assert tc.frame_rate is not None
assert tc.time_base != 1 / tc.frame_rate
def test_frame_num_and_frame_rate_are_read_only():
"""Per migration guide, `frame_num`, `frame_rate`, and the legacy `framerate` alias are
read-only properties; callers must construct a new FrameTimecode to change them."""
tc = FrameTimecode(timecode=0, fps=30.0)
for attr in ("frame_num", "frame_rate", "framerate"):
with pytest.raises(AttributeError):
setattr(tc, attr, 99)
def test_equal_frame_rate_legacy_alias():
"""`equal_framerate()` is the deprecated alias for `equal_frame_rate()` (issue #548).
Both forms should produce identical results for every accepted operand type."""
tc = FrameTimecode(timecode=0, fps=30.0)
# float, Fraction, FrameTimecode operands.
other_tc = FrameTimecode(timecode=0, fps=30.0)
for other in (30.0, Fraction(30, 1), other_tc):
expected = tc.equal_frame_rate(other)
with pytest.warns(DeprecationWarning, match="equal_frame_rate"):
actual = tc.equal_framerate(other)
assert actual == expected
assert actual is True
# Mismatched rate.
assert tc.equal_frame_rate(24.0) is False
with pytest.warns(DeprecationWarning, match="equal_frame_rate"):
assert tc.equal_framerate(24.0) is False
def test_timecode_numeric():
"""Test FrameTimecode constructor argument "timecode" with numeric arguments."""
with pytest.raises(ValueError):
FrameTimecode(timecode=-1, fps=1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode=-1.0, fps=1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode=-0.1, fps=1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode=-1.0 / 1000, fps=1.0)
assert FrameTimecode(timecode=0, fps=1.0).frame_num == 0
assert FrameTimecode(timecode=1, fps=1.0).frame_num == 1
assert FrameTimecode(timecode=0.0, fps=1.0).frame_num == 0
assert FrameTimecode(timecode=1.0, fps=1.0).frame_num == 1
def test_timecode_string():
"""Test FrameTimecode constructor argument "timecode" with string arguments."""
# Invalid strings:
with pytest.raises(ValueError):
FrameTimecode(timecode="-1", fps=1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode="-1.0", fps=1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode="-0.1", fps=1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode="1.9x", fps=1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode="1x", fps=1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode="1.9.9", fps=1.0)
with pytest.raises(ValueError):
FrameTimecode(timecode="1.0-", fps=1.0)
# Frame number integer [int->str] ('%d', integer number as string)
assert FrameTimecode(timecode="0", fps=1.0).frame_num == 0
assert FrameTimecode(timecode="1", fps=1.0).frame_num == 1
assert FrameTimecode(timecode="10", fps=1.0).frame_num == 10
# Seconds format [float->str] ('%f', number as string)
assert FrameTimecode(timecode="0.0", fps=1.0).frame_num == 0
assert FrameTimecode(timecode="1.0", fps=1.0).frame_num == 1
assert FrameTimecode(timecode="10.0", fps=1.0).frame_num == 10
assert FrameTimecode(timecode="10.0000000000", fps=1.0).frame_num == 10
assert FrameTimecode(timecode="10.100", fps=1.0).frame_num == 10
assert FrameTimecode(timecode="1.100", fps=10.0).frame_num == 11
# Seconds format [float->str] ('%fs', number as string followed by 's' for seconds)
assert FrameTimecode(timecode="0s", fps=1.0).frame_num == 0
assert FrameTimecode(timecode="1s", fps=1.0).frame_num == 1
assert FrameTimecode(timecode="10s", fps=1.0).frame_num == 10
assert FrameTimecode(timecode="10.0s", fps=1.0).frame_num == 10
assert FrameTimecode(timecode="10.0000000000s", fps=1.0).frame_num == 10
assert FrameTimecode(timecode="10.100s", fps=1.0).frame_num == 10
assert FrameTimecode(timecode="1.100s", fps=10.0).frame_num == 11
# Standard timecode format [timecode->str] ('HH:MM:SS[.nnn]', where [.nnn] is optional)
assert FrameTimecode(timecode="00:00:01", fps=1.0).frame_num == 1
assert FrameTimecode(timecode="00:00:01.9999", fps=1.0).frame_num == 2
assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2
assert FrameTimecode(timecode="00:00:02.0001", fps=1.0).frame_num == 2
# MM:SS[.nnn] is also allowed
assert FrameTimecode(timecode="00:01", fps=1.0).frame_num == 1
assert FrameTimecode(timecode="00:01.9999", fps=1.0).frame_num == 2
assert FrameTimecode(timecode="00:02.0000", fps=1.0).frame_num == 2
assert FrameTimecode(timecode="00:02.0001", fps=1.0).frame_num == 2
# Conversion edge cases
assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10
assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5
assert FrameTimecode(timecode="00:00:00.100", fps=10.0).frame_num == 1
assert FrameTimecode(timecode="00:00:00.001", fps=1000.0).frame_num == 1
assert FrameTimecode(timecode="00:00:59.999", fps=1.0).frame_num == 60
assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60
assert FrameTimecode(timecode="00:01:00.001", fps=1.0).frame_num == 60
assert FrameTimecode(timecode="00:59:59.999", fps=1.0).frame_num == 3600
assert FrameTimecode(timecode="01:00:00.000", fps=1.0).frame_num == 3600
assert FrameTimecode(timecode="01:00:00.001", fps=1.0).frame_num == 3600
# Check too many ":" characters (https://github.com/Breakthrough/PySceneDetect/issues/476)
with pytest.raises(ValueError):
FrameTimecode(timecode="01:01:00:00.001", fps=1.0)
def test_get_frames():
"""Test FrameTimecode get_frames() method."""
assert FrameTimecode(timecode=1, fps=1.0).frame_num == 1
assert FrameTimecode(timecode=1000, fps=60.0).frame_num == 1000
assert FrameTimecode(timecode=1000000000, fps=29.97).frame_num == 1000000000
assert FrameTimecode(timecode=1.0, fps=1.0).frame_num == int(1.0 / 1.0)
assert FrameTimecode(timecode=1000.0, fps=60.0).frame_num == int(1000.0 * 60.0)
# 29.97 snaps to exact NTSC Fraction(30000, 1001), so expected is computed from that
# rational rather than the lossy float multiplication.
assert FrameTimecode(timecode=1000000000.0, fps=29.97).frame_num == round(
1000000000.0 * 30000 / 1001
)
assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).frame_num == 2
assert FrameTimecode(timecode="00:00:00.5", fps=10.0).frame_num == 5
assert FrameTimecode(timecode="00:00:01", fps=10.0).frame_num == 10
assert FrameTimecode(timecode="00:01:00.000", fps=1.0).frame_num == 60
def test_get_seconds():
"""Test FrameTimecode get_seconds() method."""
assert FrameTimecode(timecode=1, fps=1.0).seconds, pytest.approx(1.0 / 1.0)
assert FrameTimecode(timecode=1000, fps=60.0).seconds, pytest.approx(1000 / 60.0)
assert FrameTimecode(timecode=1000000000, fps=29.97).seconds, pytest.approx(1000000000 / 29.97)
assert FrameTimecode(timecode=1.0, fps=1.0).seconds, pytest.approx(1.0)
assert FrameTimecode(timecode=1000.0, fps=60.0).seconds, pytest.approx(1000.0)
assert FrameTimecode(timecode=1000000000.0, fps=29.97).seconds, pytest.approx(1000000000.0)
assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).seconds, pytest.approx(2.0)
assert FrameTimecode(timecode="00:00:00.5", fps=10.0).seconds, pytest.approx(0.5)
assert FrameTimecode(timecode="00:00:01", fps=10.0).seconds, pytest.approx(1.0)
assert FrameTimecode(timecode="00:01:00.000", fps=1.0).seconds, pytest.approx(60.0)
def test_get_timecode():
"""Test FrameTimecode get_timecode() method."""
assert FrameTimecode(timecode=1.0, fps=1.0).get_timecode() == "00:00:01.000"
assert FrameTimecode(timecode=60.117, fps=60.0).get_timecode() == "00:01:00.117"
# 29.97 snaps to exact NTSC Fraction(30000, 1001); 3600.234s lands on the nearest
# NTSC frame at ~01:00:00.230 rather than the lossy-float result of "01:00:00.234".
assert FrameTimecode(timecode=3600.234, fps=29.97).get_timecode() == "01:00:00.230"
assert FrameTimecode(timecode="00:00:02.0000", fps=1.0).get_timecode() == "00:00:02.000"
assert FrameTimecode(timecode="00:00:00.5", fps=10.0).get_timecode() == "00:00:00.500"
# If a value is provided in seconds, we store that value internally now.
assert (
FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=False)
== "00:00:01.501"
)
assert (
FrameTimecode(timecode="00:00:01.501", fps=10.0).get_timecode(nearest_frame=True)
== "00:00:01.500"
)
def test_equality():
"""Test FrameTimecode equality (==, __eq__) operator."""
x = FrameTimecode(timecode=1.0, fps=10.0)
assert x == x
assert x == FrameTimecode(timecode=1.0, fps=10.0)
assert x == FrameTimecode(timecode=1.0, fps=10.0)
assert x == FrameTimecode(timecode=1.0, fps=Fraction(10, 1))
assert x != FrameTimecode(timecode=10.0, fps=10.0)
assert x != FrameTimecode(timecode=10.0, fps=10.0)
assert x != FrameTimecode(timecode=10.0, fps=Fraction(100, 10))
assert x == FrameTimecode(x)
assert x == FrameTimecode(1.0, x)
assert x == FrameTimecode(10, x)
assert x == "00:00:01"
assert x == "00:00:01.0"
assert x == "00:00:01.00"
assert x == "00:00:01.000"
assert x == "00:00:01.0000"
assert x == "00:00:01.00000"
assert x == 10
assert x == 1.0
with pytest.raises(ValueError):
assert x == "0x"
with pytest.raises(ValueError):
assert x == "x00:00:00.000"
with pytest.raises(TypeError):
assert x == [0]
with pytest.raises(TypeError):
assert x == (0,)
with pytest.raises(TypeError):
assert x == [0, 1, 2, 3]
with pytest.raises(TypeError):
assert x == {0: 0}
assert FrameTimecode(timecode="00:00:00.5", fps=10.0) == "00:00:00.500"
assert FrameTimecode(timecode="00:00:01.500", fps=10.0) == "00:00:01.500"
def test_addition():
"""Test FrameTimecode addition (+/+=, __add__/__iadd__) operator."""
x = FrameTimecode(timecode=1.0, fps=10.0)
assert x + 1 == FrameTimecode(timecode=1.1, fps=10.0)
assert x + 1 == FrameTimecode(1.1, x)
assert x + 10 == "00:00:02.000", str(x + 10)
assert x + 10 == 20
assert x + 10 == 2.0
assert x + 10 == "00:00:02.000"
def test_subtraction():
"""Test FrameTimecode subtraction (-/-=, __sub__) operator."""
x = FrameTimecode(timecode=1.0, fps=10.0)
assert (x - 1) == FrameTimecode(timecode=0.9, fps=10.0)
assert x - 2 == FrameTimecode(0.8, x)
assert x - 10 == FrameTimecode(0.0, x)
# TODO(v1.0): Allow negative values. For now we clamp.
assert x - 11 == FrameTimecode(0.0, x)
assert x - 100 == FrameTimecode(0.0, x)
assert x - 1.0 == FrameTimecode(0.0, x)
assert x - 100.0 == FrameTimecode(0.0, x)
assert x - 1 == FrameTimecode(timecode=0.9, fps=10.0)
assert FrameTimecode("00:00:00.000", fps=20.0) == x - 10
@pytest.mark.parametrize(
"frame_num,fps", [(1, 1.0), (61, 14.0), (29, 25.0), (126, Fraction(24000, 1001))]
)
def test_identity(frame_num, fps):
"""Test FrameTimecode values, when used in init return the same values"""
frame_time_code = FrameTimecode(frame_num, fps=fps)
assert FrameTimecode(frame_time_code) == frame_time_code
assert FrameTimecode(frame_time_code.frame_num, fps=fps) == frame_time_code
assert FrameTimecode(frame_time_code.seconds, fps=fps) == frame_time_code
assert FrameTimecode(frame_time_code.get_timecode(), fps=fps) == frame_time_code
def test_precision():
"""Test rounding and precision, which has implications for rounding behavior."""
fps = 1000.0
assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.11"
assert FrameTimecode(110, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.11"
assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=True) == "00:00:00.1"
assert FrameTimecode(110, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.1"
assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=True) == "00:00:00"
assert FrameTimecode(110, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00"
assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=True) == "00:00:00.99"
assert FrameTimecode(990, fps).get_timecode(precision=2, use_rounding=False) == "00:00:00.99"
assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=True) == "00:00:01.0"
assert FrameTimecode(990, fps).get_timecode(precision=1, use_rounding=False) == "00:00:00.9"
assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=True) == "00:00:01"
assert FrameTimecode(990, fps).get_timecode(precision=0, use_rounding=False) == "00:00:00"
def test_rational_framerate_precision():
"""Rational framerates should round-trip frame/second conversions without drift."""
fps = Fraction(24000, 1001)
# Verify that frame_num round-trips through seconds without drift over many frames.
for frame in [0, 1, 100, 1000, 10000, 100000]:
tc = FrameTimecode(frame, fps)
assert tc.frame_num == frame, f"Frame {frame} drifted to {tc.frame_num}"
def test_ntsc_framerate_detection():
"""Common NTSC framerates should be detected from float values."""
assert framerate_to_fraction(23.976023976023978) == Fraction(24000, 1001)
assert framerate_to_fraction(29.97002997002997) == Fraction(30000, 1001)
assert framerate_to_fraction(59.94005994005994) == Fraction(60000, 1001)
assert framerate_to_fraction(119.88011988011988) == Fraction(120000, 1001)
assert framerate_to_fraction(24.0) == Fraction(24, 1)
assert framerate_to_fraction(30.0) == Fraction(30, 1)
assert framerate_to_fraction(60.0) == Fraction(60, 1)
assert framerate_to_fraction(25.0) == Fraction(25, 1)
def test_frame_timecode_converts_ntsc_float_fps():
"""End-to-end: passing a float NTSC rate into the FrameTimecode constructor must yield
the exact Fraction representation, not the lossy float. This is the user-facing entry
point most users hit (e.g. when a backend hands them `cap.get(CAP_PROP_FPS)`)."""
expected = {
23.976: Fraction(24000, 1001),
29.97: Fraction(30000, 1001),
59.94: Fraction(60000, 1001),
}
for fps_float, fps_exact in expected.items():
tc = FrameTimecode(0, fps_float)
assert tc.frame_rate == fps_exact, (
f"FrameTimecode(0, {fps_float}) produced {tc.frame_rate}, expected {fps_exact}"
)
assert isinstance(tc.frame_rate, Fraction)
def test_ntsc_framerate_detection_arbitrary_base():
"""NTSC detection should work for any base rate, not a hardcoded list (e.g. 48000/1001
for HFR cinema)."""
assert framerate_to_fraction(47.952047952047955) == Fraction(48000, 1001)
assert framerate_to_fraction(239.76023976023975) == Fraction(240000, 1001)
def test_ntsc_framerate_detection_low_precision():
"""Low-precision float reports (e.g. truncated to 3 decimals) should still snap to the
NTSC rational."""
assert framerate_to_fraction(23.976) == Fraction(24000, 1001)
assert framerate_to_fraction(29.97) == Fraction(30000, 1001)
def test_framerate_to_fraction_non_ntsc_fallback():
"""Non-NTSC, non-integer framerates should fall back to limit_denominator and not be
misclassified as NTSC."""
# 24.5 is not near any N*1000/1001 within tolerance, so the limit_denominator path runs.
assert framerate_to_fraction(24.5) == Fraction(49, 2)
class _StubRational:
"""Minimal `numbers.Rational` that only exposes numerator/denominator, mirroring
`av.AVRational` from PyAV 19+ (which has no `__int__`, `__round__`, or `__floor__`)."""
def __init__(self, numerator: int, denominator: int):
self.numerator = numerator
self.denominator = denominator
numbers.Rational.register(_StubRational)
def test_framerate_to_fraction_accepts_rational():
"""Any `numbers.Rational` (not just `Fraction`) must be converted exactly. PyAV >= 19.0
reports frame rates and time bases as the `av.AVRational` type."""
for num, den in [(30000, 1001), (24000, 1001), (25, 1)]:
result = framerate_to_fraction(_StubRational(num, den)) # type: ignore[arg-type]
assert isinstance(result, Fraction)
assert result == Fraction(num, den)
with pytest.raises(ValueError):
framerate_to_fraction(_StubRational(0, 1)) # type: ignore[arg-type]
def test_frame_timecode_accepts_rational_fps():
tc = FrameTimecode(0, _StubRational(24000, 1001)) # type: ignore[arg-type]
assert isinstance(tc.frame_rate, Fraction)
assert tc.frame_rate == Fraction(24000, 1001)
def test_frame_timecode_rejects_integral_fps():
"""`int`/`bool` are also `numbers.Rational` - these must raise TypeError so a misplaced
frame *count* is never silently used as a frame *rate*."""
with pytest.raises(TypeError):
FrameTimecode(0, 30) # type: ignore[arg-type]
with pytest.raises(TypeError):
FrameTimecode(0, True) # type: ignore[arg-type]
def test_timecode_normalizes_rational_time_base():
tc = Timecode(pts=1001, time_base=_StubRational(1, 30000)) # type: ignore[arg-type]
assert isinstance(tc.time_base, Fraction)
assert tc.time_base == Fraction(1, 30000)
assert tc.seconds == pytest.approx(1001 / 30000)
def test_timecode_arithmetic_mixed_time_base():
"""Arithmetic with FrameTimecodes using different time_bases should work."""
fps = Fraction(24000, 1001)
# Timecode with time_base 1/24000 (from PyAV)
tc_pyav = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps)
# Timecode with time_base 1/1000000 (from OpenCV microseconds)
tc_cv2 = FrameTimecode(timecode=Timecode(pts=41708, time_base=Fraction(1, 1000000)), fps=fps)
# Both represent approximately 1 frame duration. Addition/subtraction shouldn't raise.
result = tc_pyav + tc_cv2
assert result.seconds > 0
result = tc_pyav - tc_cv2
assert result.seconds >= 0 # Clamped to 0 if negative
def test_timecode_frame_num_for_vfr():
"""frame_num should return approximate values for Timecode-backed objects without warning."""
fps = Fraction(24000, 1001)
tc = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps)
# Should not raise or warn - just return the approximate frame number.
assert tc.frame_num == 1
def test_arithmetic_with_bare_timecode():
"""`FrameTimecode` arithmetic should accept a bare :class:`Timecode` operand by treating
it as an absolute time in seconds."""
fps = 30.0
base = FrameTimecode(timecode=10, fps=fps) # 10 frames @ 30fps == ~0.333s
# 1/30s expressed in a 1/1000 time base is pts=33 (rounded).
one_frame_at_30 = Timecode(pts=33, time_base=Fraction(1, 1000))
plus = base + one_frame_at_30
assert plus.frame_num == 11
minus = base - one_frame_at_30
assert minus.frame_num == 9
# Reverse direction: a Timecode-backed FrameTimecode plus a bare Timecode.
pts_base = FrameTimecode(timecode=Timecode(pts=1, time_base=Fraction(1, 1000)), fps=fps)
pts_plus = pts_base + Timecode(pts=2, time_base=Fraction(1, 1000))
assert pts_plus.seconds == pytest.approx(0.003)
def test_comparisons_with_bare_timecode():
"""`FrameTimecode` comparison operators should accept a bare :class:`Timecode` operand."""
fps = 30.0
half_second_frame = FrameTimecode(timecode=15, fps=fps)
half_second_tc = Timecode(pts=500, time_base=Fraction(1, 1000))
one_second_tc = Timecode(pts=1000, time_base=Fraction(1, 1000))
assert half_second_frame == half_second_tc
assert half_second_frame != one_second_tc
assert half_second_frame < one_second_tc
assert half_second_frame <= half_second_tc
assert one_second_tc != half_second_frame # reflected via __ne__
assert FrameTimecode(timecode=30, fps=fps) > half_second_tc
assert FrameTimecode(timecode=15, fps=fps) >= half_second_tc
def test_exact_comparison_same_rate():
"""Timecode-backed instances with the same rate compare by exact PTS, not rounded frame
numbers. pts=999 and pts=1001 @ time_base 1/1000 both round to frame 30 @ 30fps, but
represent different presentation times."""
fps = Fraction(30, 1)
a = FrameTimecode(timecode=Timecode(pts=999, time_base=Fraction(1, 1000)), fps=fps)
b = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 1000)), fps=fps)
assert a.frame_num == b.frame_num == 30 # Rounding collides...
assert a != b # ...but exact times differ.
assert not (a == b) # noqa: SIM201 - deliberately exercises __eq__, not just __ne__.
assert a < b and a <= b
assert b > a and b >= a
assert not (a > b) and not (a >= b)
# Hash may still collide (frame_num-based); that is legal since a != b.
assert hash(a) == hash(b)
# Sets/sorting now distinguish and correctly order the two times.
assert len({a, b}) == 2
assert sorted([b, a]) == [a, b]
def test_exact_equality_across_time_base_representations():
"""Equal exact times expressed in different time bases are equal, and hashes agree."""
fps = Fraction(30, 1)
a = FrameTimecode(timecode=Timecode(pts=500, time_base=Fraction(1, 1000)), fps=fps)
b = FrameTimecode(timecode=Timecode(pts=1000, time_base=Fraction(1, 2000)), fps=fps)
assert a == b and not (a != b) # noqa: SIM202 - deliberately exercises both operators.
assert a <= b and a >= b
assert not (a < b) and not (a > b)
assert hash(a) == hash(b)
assert len({a, b}) == 1
def test_exact_comparison_requires_same_rate():
"""Timecode-backed instances with DIFFERENT rates keep legacy frame-number comparison."""
a = FrameTimecode(timecode=Timecode(pts=999, time_base=Fraction(1, 1000)), fps=30.0)
b = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 1000)), fps=30.0)
c = FrameTimecode(b, fps=Fraction(30000, 1001)) # Same time as b, different rate.
# Cross-rate falls back to frame_num comparison:
assert (a == c) == (a.frame_num == c.frame_num)
# Same-rate pair still compares exactly:
assert a != b
def test_cross_rate_frame_number_equality_unchanged():
"""Legacy behavior pinned: rated, non-Timecode-backed instances with different rates still
compare by frame number."""
assert FrameTimecode(timecode=100, fps=25.0) == FrameTimecode(timecode=100, fps=30.0)
def test_mixed_representation_comparison_unchanged():
"""Timecode-backed vs frame-backed comparison still uses frame numbers."""
fps = Fraction(24000, 1001)
vfr = FrameTimecode(timecode=Timecode(pts=1001, time_base=Fraction(1, 24000)), fps=fps)
assert vfr.frame_num == 1
assert vfr == FrameTimecode(timecode=1, fps=fps)
def test_min_scene_len_accepts_timecode_like():
"""Detector ``min_scene_len`` and FlashFilter ``length`` should accept any TimecodeLike,
including :class:`FrameTimecode` / :class:`Timecode`."""
from scenedetect.detector import FlashFilter
from scenedetect.detectors import ContentDetector
# FlashFilter: int, float, str, FrameTimecode, Timecode all valid.
FlashFilter(mode=FlashFilter.Mode.MERGE, length=15)
FlashFilter(mode=FlashFilter.Mode.MERGE, length=0.5)
FlashFilter(mode=FlashFilter.Mode.MERGE, length="00:00:00.500")
FlashFilter(mode=FlashFilter.Mode.MERGE, length=FrameTimecode(timecode=15, fps=30.0))
FlashFilter(
mode=FlashFilter.Mode.MERGE,
length=Timecode(pts=500, time_base=Fraction(1, 1000)),
)
# ContentDetector: same.
ContentDetector(min_scene_len=FrameTimecode(timecode=15, fps=30.0))
ContentDetector(min_scene_len=Timecode(pts=500, time_base=Fraction(1, 1000)))
# FlashFilter NTSC regression tests (#577). An integer `length` (frames) was converted to seconds
# with `length / float(frame_rate)`, which can land one ulp above the true value at NTSC rates, so
# a gap of exactly N frames failed the `>=` check and the cut was dropped. These tests feed
# FlashFilter the exact positions a backend reports for an NTSC stream (no video file needed).
NTSC_RATES = (
Fraction(30000, 1001),
Fraction(60000, 1001),
)
def _ntsc_pts_position(frame: int, fps: Fraction) -> FrameTimecode:
"""Position of `frame` as a PTS-exact backend (e.g. PyAV) reports it.
For an NTSC rate ``num/1001`` with ``time_base = 1/num`` each frame advances exactly ``1001``
PTS units.
"""
return FrameTimecode(Timecode(pts=frame * 1001, time_base=Fraction(1, fps.numerator)), fps)
def _ntsc_microsecond_position(frame: int, fps: Fraction) -> FrameTimecode:
"""Position of `frame` as the OpenCV backend reports it, rounded to whole microseconds."""
us = round(frame * 1_000_000 / fps)
return FrameTimecode(Timecode(pts=us, time_base=Fraction(1, 1_000_000)), fps)
@pytest.mark.parametrize("fps", NTSC_RATES)
@pytest.mark.parametrize("mode", [FlashFilter.Mode.MERGE, FlashFilter.Mode.SUPPRESS])
def test_flashfilter_emits_cut_every_n_frames_at_ntsc(fps: Fraction, mode: FlashFilter.Mode):
"""A cut exactly `length` frames after the previous one is kept at NTSC rates."""
flash_filter = FlashFilter(mode=mode, length=15)
emitted = []
for frame in range(300):
emitted += flash_filter.filter(
_ntsc_pts_position(frame, fps), above_threshold=frame > 0 and frame % 15 == 0
)
assert [t.frame_num for t in emitted] == list(range(15, 300, 15))
@pytest.mark.parametrize("fps", NTSC_RATES)
def test_flashfilter_keeps_exact_gap_at_ntsc(fps: Fraction):
"""The exact N-frame gap must satisfy the minimum-length check at NTSC rates."""
gap = _ntsc_pts_position(115, fps) - _ntsc_pts_position(100, fps)
assert gap.frame_num == 15
assert gap >= 15
@pytest.mark.parametrize("fps", NTSC_RATES)
@pytest.mark.parametrize("mode", [FlashFilter.Mode.MERGE, FlashFilter.Mode.SUPPRESS])
def test_flashfilter_keeps_cuts_on_microsecond_positions(fps: Fraction, mode: FlashFilter.Mode):
"""OpenCV rounds positions to microseconds, so an N-frame gap can be 1 us short of N / fps.
With min_scene_len=17 on 29.97 fps (the example in #577) cuts were dropped.
"""
flash_filter = FlashFilter(mode=mode, length=17)
emitted = []
for frame in range(17 * 20):
emitted += flash_filter.filter(
_ntsc_microsecond_position(frame, fps), above_threshold=frame > 0 and frame % 17 == 0
)
assert [t.frame_num for t in emitted] == list(range(17, 17 * 20, 17))
def test_get_framerate():
"""`get_framerate()` emits one warning and preserves its legacy float return value."""
tc = FrameTimecode(timecode=0, fps=30.0)
with pytest.warns(DeprecationWarning, match="frame_rate") as warning_info:
frame_rate = tc.get_framerate()
assert len(warning_info) == 1
assert frame_rate == 30.0
assert isinstance(frame_rate, float)
You can’t perform that action at this time.
