Skip to content

Commit 0a8b68d

Browse files
committed
[timecode] Fix remaining timecode issues
1 parent 0464ccf commit 0a8b68d

File tree

4 files changed

+58
-21
lines changed

4 files changed

+58
-21
lines changed

scenedetect/_cli/controller.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,15 @@ def _load_scenes(context: CliContext) -> ty.Tuple[SceneList, CutList]:
175175
if context.load_scenes_column_name not in csv_headers:
176176
raise ValueError("specified column header for scene start is not present")
177177
col_idx = csv_headers.index(context.load_scenes_column_name)
178-
cut_list = sorted(
179-
FrameTimecode(row[col_idx], fps=context.video_stream.frame_rate) - 1
180-
for row in file_reader
181-
)
178+
179+
def calculate_timecode(value: str) -> FrameTimecode:
180+
# Assume other columns are in seconds except frame numbers.
181+
if value.isdigit():
182+
# Frame numbers start from index 1 in the CLI output so we correct for that.
183+
return FrameTimecode(int(value) - 1, fps=context.video_stream.frame_rate)
184+
return FrameTimecode(value, fps=context.video_stream.frame_rate)
185+
186+
cut_list = sorted(calculate_timecode(row[col_idx]) for row in file_reader)
182187
# `SceneDetector` works on cuts, so we have to skip the first scene and place the first
183188
# cut point where the next scenes starts.
184189
if cut_list:

scenedetect/common.py

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -207,17 +207,27 @@ def __init__(
207207
):
208208
raise ValueError("Framerate must be positive and greater than zero.")
209209
self._framerate = float(fps)
210-
# Process the timecode value, storing it as an exact number of frames.
210+
# Process the timecode value, storing it as an exact number of frames only if required.
211+
if isinstance(timecode, str) and timecode.isdigit():
212+
timecode = int(timecode)
211213
if isinstance(timecode, str):
212214
self._seconds = self._timecode_to_seconds(timecode)
213-
self._frame_num = self._seconds_to_frames(self._seconds)
215+
elif isinstance(timecode, float):
216+
if timecode < 0.0:
217+
raise ValueError("Timecode frame number must be positive and greater than zero.")
218+
self._seconds = timecode
219+
elif isinstance(timecode, int):
220+
if timecode < 0:
221+
raise ValueError("Timecode frame number must be positive and greater than zero.")
222+
self._frame_num = timecode
214223
else:
215-
self._frame_num = self._parse_timecode_number(timecode)
216-
self._seconds = timecode if isinstance(timecode, float) else None
224+
raise TypeError("Timecode format/type unrecognized.")
217225

218226
@property
219227
def frame_num(self) -> ty.Optional[int]:
220228
if self._timecode:
229+
# We need to audit anything currently using this property to guarantee temporal
230+
# consistency when handling VFR videos (i.e. no assumptions on fixed frame rate).
221231
warnings.warn(
222232
message="TODO(https://scenedetect.com/issue/168): Update caller to handle VFR.",
223233
stacklevel=2,
@@ -227,6 +237,8 @@ def frame_num(self) -> ty.Optional[int]:
227237
# time base itself.
228238
(num, den) = (self._timecode.time_base * self._timecode.pts).as_integer_ratio()
229239
return num / den
240+
if self._seconds is not None:
241+
return self._seconds_to_frames(self._seconds)
230242
return self._frame_num
231243

232244
@property
@@ -306,19 +318,26 @@ def get_seconds(self) -> float:
306318
)
307319
return self.seconds
308320

309-
def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str:
321+
def get_timecode(
322+
self, precision: int = 3, use_rounding: bool = True, nearest_frame: bool = True
323+
) -> str:
310324
"""Get a formatted timecode string of the form HH:MM:SS[.nnn].
311325
312326
Args:
313327
precision: The number of decimal places to include in the output ``[.nnn]``.
314328
use_rounding: Rounds the output to the desired precision. If False, the value
315329
will be truncated to the specified precision.
330+
nearest_frame: Ensures that the timecode is moved to the nearest frame boundary if this
331+
object has a defined framerate, otherwise has no effect.
316332
317333
Returns:
318334
str: The current time in the form ``"HH:MM:SS[.nnn]"``.
319335
"""
320336
# Compute hours and minutes based off of seconds, and update seconds.
321-
secs = self.seconds
337+
if nearest_frame and self.framerate:
338+
secs = self.frame_num / self.framerate
339+
else:
340+
secs = self.seconds
322341
hrs = int(secs / _SECONDS_PER_HOUR)
323342
secs -= hrs * _SECONDS_PER_HOUR
324343
mins = int(secs / _SECONDS_PER_MINUTE)
@@ -440,7 +459,7 @@ def __eq__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
440459
if other is None:
441460
return False
442461
if _compare_as_fixed(self, other):
443-
return self._frame_num == other._frame_num
462+
return self.frame_num == other.frame_num
444463
if self._timecode or self._seconds is not None:
445464
return self.seconds == self._get_other_as_seconds(other)
446465
return self.frame_num == self._get_other_as_frames(other)
@@ -449,35 +468,35 @@ def __ne__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
449468
if other is None:
450469
return True
451470
if _compare_as_fixed(self, other):
452-
return self._frame_num != other._frame_num
471+
return self.frame_num != other.frame_num
453472
if self._timecode or self._seconds is not None:
454473
return self.seconds != self._get_other_as_seconds(other)
455474
return self.frame_num != self._get_other_as_frames(other)
456475

457476
def __lt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
458477
if _compare_as_fixed(self, other):
459-
return self._frame_num < other._frame_num
478+
return self.frame_num < other.frame_num
460479
if self._timecode or self._seconds is not None:
461480
return self.seconds < self._get_other_as_seconds(other)
462481
return self.frame_num < self._get_other_as_frames(other)
463482

464483
def __le__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
465484
if _compare_as_fixed(self, other):
466-
return self._frame_num <= other._frame_num
485+
return self.frame_num <= other.frame_num
467486
if self._timecode or self._seconds is not None:
468487
return self.seconds <= self._get_other_as_seconds(other)
469488
return self.frame_num <= self._get_other_as_frames(other)
470489

471490
def __gt__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
472491
if _compare_as_fixed(self, other):
473-
return self._frame_num > other._frame_num
492+
return self.frame_num > other.frame_num
474493
if self._timecode or self._seconds is not None:
475494
return self.seconds > self._get_other_as_seconds(other)
476495
return self.frame_num > self._get_other_as_frames(other)
477496

478497
def __ge__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> bool:
479498
if _compare_as_fixed(self, other):
480-
return self._frame_num >= other._frame_num
499+
return self.frame_num >= other.frame_num
481500
if self._timecode or self._seconds is not None:
482501
return self.seconds >= self._get_other_as_seconds(other)
483502
return self.frame_num >= self._get_other_as_frames(other)
@@ -513,9 +532,11 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT
513532
self._seconds = max(0, self._seconds + other._seconds)
514533
return self
515534

516-
self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other))
517535
if self._seconds is not None:
518536
self._seconds = max(0.0, self._seconds + self._get_other_as_seconds(other))
537+
return self
538+
539+
self._frame_num = max(0, self._frame_num + self._get_other_as_frames(other))
519540
return self
520541

521542
def __add__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode":
@@ -554,9 +575,11 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT
554575
self._seconds = max(0, self._seconds - other._seconds)
555576
return self
556577

557-
self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other))
558578
if self._seconds is not None:
559579
self._seconds = max(0.0, self._seconds - self._get_other_as_seconds(other))
580+
return self
581+
582+
self._frame_num = max(0, self._frame_num - self._get_other_as_frames(other))
560583
return self
561584

562585
def __sub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameTimecode":
@@ -579,7 +602,9 @@ def __str__(self) -> str:
579602
def __repr__(self) -> str:
580603
if self._timecode:
581604
return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]"
582-
return "%s [frame=%d, fps=%.3f]" % (self.get_timecode(), self._frame_num, self._framerate)
605+
if self._seconds is not None:
606+
return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._framerate}]"
607+
return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._framerate}]"
583608

584609
def __hash__(self) -> int:
585610
if self._timecode:

tests/test_timecode.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,14 @@ def test_get_timecode():
188188
assert FrameTimecode(timecode="00:00:02.0000", fps=1).get_timecode() == "00:00:02.000"
189189
assert FrameTimecode(timecode="00:00:00.5", fps=10).get_timecode() == "00:00:00.500"
190190
# If a value is provided in seconds, we store that value internally now.
191-
assert FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode() == "00:00:01.501"
192-
assert FrameTimecode(timecode="00:01:00.000", fps=1).get_timecode() == "00:01:00.000"
191+
assert (
192+
FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=False)
193+
== "00:00:01.501"
194+
)
195+
assert (
196+
FrameTimecode(timecode="00:00:01.501", fps=10).get_timecode(nearest_frame=True)
197+
== "00:00:01.500"
198+
)
193199

194200

195201
def test_equality():

website/pages/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,3 +713,4 @@ Although there have been minimal changes to most API examples, there are several
713713
* Deprecated functionality preserved from v0.6 now uses the `warnings` module
714714
* Add properties to access `frame_num`, `framerate`, and `seconds` from `FrameTimecode` instead of getter methods
715715
* Add new `Timecode` type to represent frame timings in terms of the video's source timebase
716+
* Expand `FrameTimecode` representations to preserve accuracy (previously all timecodes were rounded to frame boundaries)

0 commit comments

Comments
 (0)