Skip to content

Commit e2c11c9

Browse files
committed
[timecode] Expand PTS support #168
Allows much more tests to pass now, down to just a few.
1 parent 78d7fa4 commit e2c11c9

File tree

4 files changed

+56
-25
lines changed

4 files changed

+56
-25
lines changed

scenedetect/backends/opencv.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,14 @@ def position(self) -> FrameTimecode:
209209
# TODO(https://scenedetect.com/issue/168): See if there is a better way to do this, or
210210
# add a config option before landing this.
211211
if _USE_PTS_IN_DEVELOPMENT:
212-
return FrameTimecode(timecode=self.timecode, fps=self.frame_rate)
212+
timecode = self.timecode
213+
# If PTS is 0 but we've read frames, derive from frame number.
214+
# This handles image sequences and cases where CAP_PROP_POS_MSEC is unreliable.
215+
if timecode.pts == 0 and self.frame_number > 0:
216+
time_sec = (self.frame_number - 1) / self.frame_rate
217+
pts = round(time_sec * 1000)
218+
timecode = Timecode(pts=pts, time_base=Fraction(1, 1000))
219+
return FrameTimecode(timecode=timecode, fps=self.frame_rate)
213220
if self.frame_number < 1:
214221
return self.base_timecode
215222
return self.base_timecode + (self.frame_number - 1)

scenedetect/backends/pyav.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,11 @@ def position(self) -> FrameTimecode:
182182
183183
This can be interpreted as presentation time stamp, thus frame 1 corresponds
184184
to the presentation time 0. Returns 0 even if `frame_number` is 1."""
185+
if self._frame is None:
186+
return self.base_timecode
185187
if _USE_PTS_IN_DEVELOPMENT:
186188
timecode = Timecode(pts=self._frame.pts, time_base=self._frame.time_base)
187189
return FrameTimecode(timecode=timecode, fps=self.frame_rate)
188-
if self._frame is None:
189-
return self.base_timecode
190190
return FrameTimecode(round(self._frame.time * self.frame_rate), self.frame_rate)
191191

192192
@property
@@ -205,9 +205,8 @@ def frame_number(self) -> int:
205205

206206
if self._frame:
207207
if _USE_PTS_IN_DEVELOPMENT:
208-
return FrameTimecode(
209-
round(self._frame.time * self.frame_rate), self.frame_rate
210-
).frame_num
208+
# frame_number is 1-indexed, so add 1 to the 0-based frame position.
209+
return round(self._frame.time * self.frame_rate) + 1
211210
return self.position.frame_num + 1
212211
return 0
213212

scenedetect/common.py

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -252,18 +252,22 @@ def frame_num(self) -> ty.Optional[int]:
252252
stacklevel=2,
253253
category=UserWarning,
254254
)
255-
# We can calculate the approx. # of frames by taking the presentation time and the
256-
# time base itself.
257-
(num, den) = (self._time.time_base * self._time.pts).as_integer_ratio()
258-
return num / den
255+
# Calculate approximate frame number from seconds and framerate.
256+
if self._rate is not None:
257+
return round(self._time.seconds * float(self._rate))
258+
# No framerate available - return estimate based on time.
259+
return round(self._time.seconds)
259260
if isinstance(self._time, _Seconds):
260261
return self._seconds_to_frames(self._time.value)
261262
return self._time.value
262263

263264
@property
264-
def framerate(self) -> float:
265+
def framerate(self) -> ty.Optional[float]:
265266
"""The framerate to use for distance between frames and to calculate frame numbers.
266-
For a VFR video, this may just be the average framerate."""
267+
For a VFR video, this may just be the average framerate. Returns None if framerate
268+
is unknown (e.g. when working with pure Timecode representations)."""
269+
if self._rate is None:
270+
return None
267271
return float(self._rate)
268272

269273
@property
@@ -499,6 +503,9 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
499503
return False
500504
if _compare_as_fixed(self, other):
501505
return self.frame_num == other.frame_num
506+
# For integer comparison, use frame numbers to avoid floating point precision issues.
507+
if isinstance(other, int):
508+
return self.frame_num == other
502509
if isinstance(self._time, (Timecode, _Seconds)):
503510
return self.seconds == self._get_other_as_seconds(other)
504511
return self.frame_num == self._get_other_as_frames(other)
@@ -508,34 +515,49 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
508515
return True
509516
if _compare_as_fixed(self, other):
510517
return self.frame_num != other.frame_num
518+
# For integer comparison, use frame numbers to avoid floating point precision issues.
519+
if isinstance(other, int):
520+
return self.frame_num != other
511521
if isinstance(self._time, (Timecode, _Seconds)):
512522
return self.seconds != self._get_other_as_seconds(other)
513523
return self.frame_num != self._get_other_as_frames(other)
514524

515525
def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
516526
if _compare_as_fixed(self, other):
517527
return self.frame_num < other.frame_num
528+
# For integer comparison, use frame numbers to avoid floating point precision issues.
529+
if isinstance(other, int):
530+
return self.frame_num < other
518531
if isinstance(self._time, (Timecode, _Seconds)):
519532
return self.seconds < self._get_other_as_seconds(other)
520533
return self.frame_num < self._get_other_as_frames(other)
521534

522535
def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
523536
if _compare_as_fixed(self, other):
524537
return self.frame_num <= other.frame_num
538+
# For integer comparison, use frame numbers to avoid floating point precision issues.
539+
if isinstance(other, int):
540+
return self.frame_num <= other
525541
if isinstance(self._time, (Timecode, _Seconds)):
526542
return self.seconds <= self._get_other_as_seconds(other)
527543
return self.frame_num <= self._get_other_as_frames(other)
528544

529545
def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
530546
if _compare_as_fixed(self, other):
531547
return self.frame_num > other.frame_num
548+
# For integer comparison, use frame numbers to avoid floating point precision issues.
549+
if isinstance(other, int):
550+
return self.frame_num > other
532551
if isinstance(self._time, (Timecode, _Seconds)):
533552
return self.seconds > self._get_other_as_seconds(other)
534553
return self.frame_num > self._get_other_as_frames(other)
535554

536555
def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
537556
if _compare_as_fixed(self, other):
538557
return self.frame_num >= other.frame_num
558+
# For integer comparison, use frame numbers to avoid floating point precision issues.
559+
if isinstance(other, int):
560+
return self.frame_num >= other
539561
if isinstance(self._time, (Timecode, _Seconds)):
540562
return self.seconds >= self._get_other_as_seconds(other)
541563
return self.frame_num >= self._get_other_as_frames(other)
@@ -565,7 +587,9 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT
565587
pts=max(0, timecode.pts + round(seconds / timecode.time_base)),
566588
time_base=timecode.time_base,
567589
)
568-
self._rate = None
590+
# Preserve rate if available from self or other.
591+
if self._rate is None and isinstance(other, FrameTimecode):
592+
self._rate = other._rate
569593
return self
570594

571595
other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds)
@@ -610,7 +634,9 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT
610634
pts=max(0, timecode.pts - round(seconds / timecode.time_base)),
611635
time_base=timecode.time_base,
612636
)
613-
self._rate = None
637+
# Preserve rate if available from self or other.
638+
if self._rate is None and isinstance(other, FrameTimecode):
639+
self._rate = other._rate
614640
return self
615641

616642
other_is_seconds = isinstance(other, FrameTimecode) and isinstance(other._time, _Seconds)
@@ -652,21 +678,20 @@ def __repr__(self) -> str:
652678
return f"{self.get_timecode()} [frame_num={self._time.value}, fps={self._rate}]"
653679

654680
def __hash__(self) -> int:
655-
if isinstance(self._time, Timecode):
656-
return hash(self._time)
681+
# Use frame_num for consistent hashing regardless of internal representation.
682+
# This ensures that FrameTimecodes representing the same frame have the same hash,
683+
# enabling proper dictionary lookups in StatsManager.
657684
return self.frame_num
658685

659686
def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> float:
660687
"""Get the time in seconds from `other` for arithmetic operations."""
661688
if isinstance(other, int):
662-
if isinstance(self._time, Timecode):
663-
# TODO(https://scenedetect.com/issue/168): We need to convert every place that uses
664-
# frame numbers with timestamps to convert to a non-frame based way of temporal
665-
# logic and instead use seconds-based.
666-
if _USE_PTS_IN_DEVELOPMENT and other == 1:
667-
return self.seconds
668-
raise NotImplementedError()
669-
return float(other) / self._rate
689+
# Convert frame number to seconds using framerate.
690+
if self._rate is None:
691+
raise NotImplementedError(
692+
"Cannot convert frame number to seconds without framerate"
693+
)
694+
return float(other) / float(self._rate)
670695
if isinstance(other, float):
671696
return other
672697
if isinstance(other, str):

scenedetect/scene_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ def _process_frame(
404404
for cut in cuts:
405405
for position, frame in self._frame_buffer:
406406
if cut == position:
407-
callback(frame, position)
407+
callback(frame, int(position))
408408
return new_cuts
409409

410410
def _post_process(self, timecode: FrameTimecode) -> None:

0 commit comments

Comments
 (0)