Skip to content

Commit d0af8c6

Browse files
committed
[timecode] Add pts and time base properties
1 parent e86147f commit d0af8c6

File tree

3 files changed

+47
-25
lines changed

3 files changed

+47
-25
lines changed

scenedetect/common.py

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -172,18 +172,21 @@ def __init__(
172172
TypeError: Thrown if either `timecode` or `fps` are unsupported types.
173173
ValueError: Thrown when specifying a negative timecode or framerate.
174174
"""
175-
# The following two properties are what is used to keep track of time
176-
# in a frame-specific manner. Note that once the framerate is set,
177-
# the value should never be modified (only read if required).
178-
# TODO(v1.0): Make these actual @properties.
179-
self._framerate: Fraction = None
175+
# NOTE: FrameTimecode will have either a `Timecode` representation, a `seconds`
176+
# representation, or only a frame number. We cache the calculated values for later use
177+
# for the parameters that are missing.
178+
self._rate: Fraction = None
179+
"""Rate at which time passes between frames, measured in frames/sec."""
180180
self._frame_num = None
181+
"""Frame number which may be estimated."""
181182
self._timecode: ty.Optional[Timecode] = None
183+
"""Presentation timestamp from the backend."""
182184
self._seconds: ty.Optional[float] = None
185+
"""An explicit point in time."""
183186

184187
# Copy constructor.
185188
if isinstance(timecode, FrameTimecode):
186-
self._framerate = timecode._framerate if fps is None else fps
189+
self._rate = timecode._rate if fps is None else fps
187190
self._frame_num = timecode._frame_num
188191
self._timecode = timecode._timecode
189192
self._seconds = timecode._seconds
@@ -196,15 +199,15 @@ def __init__(
196199
if fps is None:
197200
raise TypeError("fps is a required argument.")
198201
if isinstance(fps, FrameTimecode):
199-
self._framerate = fps._framerate
202+
self._rate = fps._rate
200203
elif isinstance(fps, float):
201204
if fps <= MAX_FPS_DELTA:
202205
raise ValueError("Framerate must be positive and greater than zero.")
203-
self._framerate = Fraction.from_float(fps)
206+
self._rate = Fraction.from_float(fps)
204207
elif isinstance(fps, Fraction):
205208
if float(fps) <= MAX_FPS_DELTA:
206209
raise ValueError("Framerate must be positive and greater than zero.")
207-
self._framerate = fps
210+
self._rate = fps
208211
else:
209212
raise TypeError(
210213
f"Wrong type for fps: {type(fps)} - expected float, Fraction, or FrameTimecode"
@@ -232,6 +235,8 @@ def __init__(
232235

233236
@property
234237
def frame_num(self) -> ty.Optional[int]:
238+
"""The frame number. This value will be an estimate if the video is VFR. Prefer using the
239+
`pts` property."""
235240
if self._timecode:
236241
# We need to audit anything currently using this property to guarantee temporal
237242
# consistency when handling VFR videos (i.e. no assumptions on fixed frame rate).
@@ -249,8 +254,24 @@ def frame_num(self) -> ty.Optional[int]:
249254
return self._frame_num
250255

251256
@property
252-
def framerate(self) -> ty.Optional[float]:
253-
return float(self._framerate)
257+
def framerate(self) -> float:
258+
"""The framerate to use for distance between frames and to calculate frame numbers.
259+
For a VFR video, this may just be the average framerate."""
260+
return float(self._rate)
261+
262+
@property
263+
def time_base(self) -> Fraction:
264+
"""The time base in which presentation time is calculated."""
265+
if self._timecode:
266+
return self._timecode.time_base
267+
return 1 / self._rate
268+
269+
@property
270+
def pts(self) -> int:
271+
"""The presentation timestamp of the frame in units of `time_base`."""
272+
if self._timecode:
273+
return self._timecode.pts
274+
return self.frame_num
254275

255276
def get_frames(self) -> int:
256277
"""[DEPRECATED] Get the current time/position in number of frames.
@@ -302,8 +323,7 @@ def seconds(self) -> float:
302323
return self._timecode.seconds
303324
if self._seconds:
304325
return self._seconds
305-
# Assume constant framerate if we don't have timing information.
306-
return float(self._frame_num) / self._framerate
326+
return float(self._frame_num / self._rate)
307327

308328
def get_seconds(self) -> float:
309329
"""[DEPRECATED] Get the frame's position in number of seconds.
@@ -372,7 +392,7 @@ def _seconds_to_frames(self, seconds: float) -> int:
372392
373393
*NOTE*: This will not be correct for variable framerate videos.
374394
"""
375-
return round(seconds * self._framerate)
395+
return round(seconds * self._rate)
376396

377397
def _parse_timecode_number(self, timecode: ty.Union[int, float]) -> int:
378398
"""Parse a timecode number, storing it as the exact number of frames.
@@ -406,7 +426,7 @@ def _timecode_to_seconds(self, input: str) -> float:
406426
Raises:
407427
ValueError: Value could not be parsed correctly.
408428
"""
409-
assert self._framerate is not None and self._framerate > MAX_FPS_DELTA
429+
assert self._rate is not None and self._rate > MAX_FPS_DELTA
410430
input = input.strip()
411431
# Exact number of frames N
412432
if input.isdigit():
@@ -452,7 +472,7 @@ def _get_other_as_frames(self, other: ty.Union[int, float, str, "FrameTimecode"]
452472
return self._seconds_to_frames(self._timecode_to_seconds(other))
453473
if isinstance(other, FrameTimecode):
454474
# If comparing two FrameTimecodes, they must have the same framerate for frame-based operations.
455-
if self._framerate and other._framerate and not self.equal_framerate(other._framerate):
475+
if self._rate and other._rate and not self.equal_framerate(other._rate):
456476
raise ValueError(
457477
"FrameTimecode instances require equal framerate for frame-based arithmetic."
458478
)
@@ -530,7 +550,7 @@ def __iadd__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT
530550
time_base=timecode.time_base,
531551
)
532552
self._seconds = None
533-
self._framerate = None
553+
self._rate = None
534554
self._frame_num = None
535555
return self
536556

@@ -573,7 +593,7 @@ def __isub__(self, other: ty.Union[int, float, str, "FrameTimecode"]) -> "FrameT
573593
time_base=timecode.time_base,
574594
)
575595
self._seconds = None
576-
self._framerate = None
596+
self._rate = None
577597
self._frame_num = None
578598
return self
579599

@@ -610,8 +630,8 @@ def __repr__(self) -> str:
610630
if self._timecode:
611631
return f"{self.get_timecode()} [pts={self._timecode.pts}, time_base={self._timecode.time_base}]"
612632
if self._seconds is not None:
613-
return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._framerate}]"
614-
return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._framerate}]"
633+
return f"{self.get_timecode()} [seconds={self._seconds}, fps={self._rate}]"
634+
return f"{self.get_timecode()} [frame_num={self._frame_num}, fps={self._rate}]"
615635

616636
def __hash__(self) -> int:
617637
if self._timecode:
@@ -628,7 +648,7 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"
628648
if _USE_PTS_IN_DEVELOPMENT and other == 1:
629649
return self.seconds
630650
raise NotImplementedError()
631-
return float(other) / self._framerate
651+
return float(other) / self._rate
632652
if isinstance(other, float):
633653
return other
634654
if isinstance(other, str):
@@ -639,4 +659,4 @@ def _get_other_as_seconds(self, other: ty.Union[int, float, str, "FrameTimecode"
639659

640660

641661
def _compare_as_fixed(a: FrameTimecode, b: ty.Any) -> bool:
642-
return a._framerate is not None and isinstance(b, FrameTimecode) and b._framerate is not None
662+
return a._rate is not None and isinstance(b, FrameTimecode) and b._rate is not None

scenedetect/output/image.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,8 @@ def image_save_thread(self, save_queue: queue.Queue, progress_bar: tqdm):
293293
def generate_timecode_list(self, scene_list: SceneList) -> ty.List[ty.Iterable[FrameTimecode]]:
294294
"""Generates a list of timecodes for each scene in `scene_list` based on the current config
295295
parameters."""
296-
framerate = scene_list[0][0]._framerate
296+
# TODO(v0.7): This needs to be fixed as part of PTS overhaul.
297+
framerate = scene_list[0][0].framerate
297298
# TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly.
298299
return [
299300
(
@@ -450,7 +451,7 @@ def save_images(
450451
image_num_format = "%0"
451452
image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d"
452453

453-
framerate = scene_list[0][0]._framerate
454+
framerate = scene_list[0][0]._rate
454455

455456
# TODO(v1.0): Split up into multiple sub-expressions so auto-formatter works correctly.
456457
timecode_list = [

website/pages/changelog.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,4 +713,5 @@ 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)
716+
* Add new `time_base` and `pts` properties to `FrameTimecode` to provide more accurate timing information
717+

0 commit comments

Comments
 (0)