Skip to content

Commit

Permalink
Add support for v1 timestamps file
Browse files Browse the repository at this point in the history
  • Loading branch information
moi15moi committed Feb 19, 2025
1 parent e210ea2 commit 6af8407
Show file tree
Hide file tree
Showing 3 changed files with 232 additions and 9 deletions.
107 changes: 106 additions & 1 deletion tests/test_text_file_timestamps.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,116 @@
import os
from fractions import Fraction
from pathlib import Path
from video_timestamps import RoundingMethod, TextFileTimestamps
from video_timestamps import RoundingMethod, TextFileTimestamps, TimeType

dir_path = Path(os.path.dirname(os.path.realpath(__file__)))


def test_frame_to_time_v1() -> None:
timestamps_str = "# timecode format v1\n" "Assume 30\n" "5,10,15\n"
time_scale = Fraction(1000)
rounding_method = RoundingMethod.ROUND

timestamps = TextFileTimestamps(timestamps_str, time_scale, rounding_method)

assert timestamps.pts_list == [0, 33, 67, 100, 133, 167, 233, 300, 367, 433, 500, 567]
assert timestamps.fps == Fraction(30)

# Frame 0 to 5 - 30 fps
assert timestamps.frame_to_time(0, TimeType.EXACT) == Fraction(0)
assert timestamps.frame_to_time(1, TimeType.EXACT) == Fraction(33, 1000)
assert timestamps.frame_to_time(2, TimeType.EXACT) == Fraction(67, 1000)
assert timestamps.frame_to_time(3, TimeType.EXACT) == Fraction(100, 1000)
assert timestamps.frame_to_time(4, TimeType.EXACT) == Fraction(133, 1000)
assert timestamps.frame_to_time(5, TimeType.EXACT) == Fraction(167, 1000)
# Frame 6 to 11 - 15 fps
assert timestamps.frame_to_time(6, TimeType.EXACT) == Fraction(233, 1000)
assert timestamps.frame_to_time(7, TimeType.EXACT) == Fraction(300, 1000)
assert timestamps.frame_to_time(8, TimeType.EXACT) == Fraction(367, 1000)
assert timestamps.frame_to_time(9, TimeType.EXACT) == Fraction(433, 1000)
assert timestamps.frame_to_time(10, TimeType.EXACT) == Fraction(500, 1000)
assert timestamps.frame_to_time(11, TimeType.EXACT) == Fraction(567, 1000)
# From here, we guess the ms from the last frame timestamps and fps
# The last frame is equal to (5 * 1/30 * 1000 + 6 * 1/15 * 1000) = 1700/3 = 566.666.
assert timestamps.frame_to_time(12, TimeType.EXACT) == Fraction(600, 1000) # 1700/3 + 1/30 * 1000 = 600
assert timestamps.frame_to_time(13, TimeType.EXACT) == Fraction(633, 1000) # 1700/3 + 2/30 * 1000 = round(633.33) = 633
assert timestamps.frame_to_time(14, TimeType.EXACT) == Fraction(667, 1000) # 1700/3 + 3/30 * 1000 = round(666.66) = 667


def test_time_to_frame_round_v1() -> None:
timestamps_str = "# timecode format v1\n" "Assume 30\n" "5,10,15\n"
time_scale = Fraction(1000)
rounding_method = RoundingMethod.ROUND

timestamps = TextFileTimestamps(timestamps_str, time_scale, rounding_method)

# Frame 0 to 5 - 30 fps
# precision
assert timestamps.time_to_frame(Fraction(0), TimeType.EXACT) == 0
assert timestamps.time_to_frame(Fraction(33, 1000), TimeType.EXACT) == 1
assert timestamps.time_to_frame(Fraction(67, 1000), TimeType.EXACT) == 2
assert timestamps.time_to_frame(Fraction(100, 1000), TimeType.EXACT) == 3
assert timestamps.time_to_frame(Fraction(133, 1000), TimeType.EXACT) == 4
assert timestamps.time_to_frame(Fraction(167, 1000), TimeType.EXACT) == 5
# milliseconds
assert timestamps.time_to_frame(0, TimeType.EXACT, 3) == 0
assert timestamps.time_to_frame(32, TimeType.EXACT, 3) == 0
assert timestamps.time_to_frame(33, TimeType.EXACT, 3) == 1
assert timestamps.time_to_frame(66, TimeType.EXACT, 3) == 1
assert timestamps.time_to_frame(67, TimeType.EXACT, 3) == 2
assert timestamps.time_to_frame(99, TimeType.EXACT, 3) == 2
assert timestamps.time_to_frame(100, TimeType.EXACT, 3) == 3
assert timestamps.time_to_frame(132, TimeType.EXACT, 3) == 3
assert timestamps.time_to_frame(133, TimeType.EXACT, 3) == 4
assert timestamps.time_to_frame(166, TimeType.EXACT, 3) == 4
assert timestamps.time_to_frame(167, TimeType.EXACT, 3) == 5
assert timestamps.time_to_frame(232, TimeType.EXACT, 3) == 5
# Frame 6 to 11 - 15 fps
# precision
assert timestamps.time_to_frame(Fraction(233, 1000), TimeType.EXACT) == 6
assert timestamps.time_to_frame(Fraction(300, 1000), TimeType.EXACT) == 7
assert timestamps.time_to_frame(Fraction(367, 1000), TimeType.EXACT) == 8
assert timestamps.time_to_frame(Fraction(433, 1000), TimeType.EXACT) == 9
assert timestamps.time_to_frame(Fraction(500, 1000), TimeType.EXACT) == 10
assert timestamps.time_to_frame(Fraction(567, 1000), TimeType.EXACT) == 11
# milliseconds
assert timestamps.time_to_frame(233, TimeType.EXACT, 3) == 6
assert timestamps.time_to_frame(299, TimeType.EXACT, 3) == 6
assert timestamps.time_to_frame(300, TimeType.EXACT, 3) == 7
assert timestamps.time_to_frame(366, TimeType.EXACT, 3) == 7
assert timestamps.time_to_frame(367, TimeType.EXACT, 3) == 8
assert timestamps.time_to_frame(432, TimeType.EXACT, 3) == 8
assert timestamps.time_to_frame(433, TimeType.EXACT, 3) == 9
assert timestamps.time_to_frame(499, TimeType.EXACT, 3) == 9
assert timestamps.time_to_frame(500, TimeType.EXACT, 3) == 10
assert timestamps.time_to_frame(566, TimeType.EXACT, 3) == 10
assert timestamps.time_to_frame(567, TimeType.EXACT, 3) == 11
# From here, we guess the ms from the last frame timestamps and fps
# The last frame is equal to (5 * 1/30 * 1000 + 6 * 1/15 * 1000) = 1700/3 = 566.666
assert timestamps.time_to_frame(Fraction(600, 1000), TimeType.EXACT) == 12
assert timestamps.time_to_frame(Fraction(633, 1000), TimeType.EXACT) == 13
assert timestamps.time_to_frame(Fraction(667, 1000), TimeType.EXACT) == 14
assert timestamps.time_to_frame(599, TimeType.EXACT, 3) == 11
assert timestamps.time_to_frame(600, TimeType.EXACT, 3) == 12 # 1700/3 + 1/30 * 1000 = 600
assert timestamps.time_to_frame(632, TimeType.EXACT, 3) == 12
assert timestamps.time_to_frame(633, TimeType.EXACT, 3) == 13 # 1700/3 + 2/30 * 1000 = round(633.33) = 633
assert timestamps.time_to_frame(666, TimeType.EXACT, 3) == 13
assert timestamps.time_to_frame(667, TimeType.EXACT, 3) == 14 # 1700/3 + 3/30 * 1000 = round(666.66) = 667


def test_init_v1() -> None:
timestamps_str = "# timecode format v1\n" "Assume 30\n" "5,10,15\n" "12,15,40\n"
time_scale = Fraction(1000)
rounding_method = RoundingMethod.ROUND

timestamps = TextFileTimestamps(timestamps_str, time_scale, rounding_method)

assert timestamps.time_scale == Fraction(1000)
assert timestamps.rounding_method == RoundingMethod.ROUND
assert timestamps.fps == Fraction(30)
assert timestamps.pts_list == [0, 33, 67, 100, 133, 167, 233, 300, 367, 433, 500, 567, 600, 625, 650, 675, 700]


def test_init_v2() -> None:
timestamps_str = (
"# timecode format v2\n"
Expand Down
15 changes: 11 additions & 4 deletions video_timestamps/text_file_timestamps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from io import StringIO
from pathlib import Path
from typing import Optional, Union
from warnings import warn

__all__ = ["TextFileTimestamps"]

Expand Down Expand Up @@ -40,12 +41,18 @@ def __init__(
):
if isinstance(path_to_timestamps_file_or_content, Path):
with open(path_to_timestamps_file_or_content, "r", encoding="utf-8") as f:
timestamps = TimestampsFileParser.parse_file(f)
timestamps, fps_from_file = TimestampsFileParser.parse_file(f)
else:
file = StringIO(path_to_timestamps_file_or_content)
timestamps = TimestampsFileParser.parse_file(file)


timestamps, fps_from_file = TimestampsFileParser.parse_file(file)

if fps_from_file:
if fps:
warn(
"You have setted a fps, but the timestamps file also contain a fps. We will use the timestamps file fps.",
UserWarning,
)
fps = fps_from_file

pts_list = [rounding_method(Fraction(time, pow(10, 3)) * time_scale) for time in timestamps]

Expand Down
119 changes: 115 additions & 4 deletions video_timestamps/timestamps_file_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@
from typing import Optional


class RangeV1:
def __init__(self, start_frame: int, end_frame: int, fps: Fraction):
self.start_frame = start_frame
self.end_frame = end_frame
self.fps = fps


class TimestampsFileParser:
@staticmethod
def parse_file(file_content: TextIOBase) -> list[Fraction]:
def parse_file(file_content: TextIOBase) -> tuple[list[Fraction], Optional[Fraction]]:
"""Parse timestamps from a [timestamps file](https://mkvtoolnix.download/doc/mkvmerge.html#mkvmerge.external_timestamp_files) and return them.
Inspired by: https://gitlab.com/mbunkus/mkvtoolnix/-/blob/72dfe260effcbd0e7d7cf6998c12bb35308c004f/src/merge/timestamp_factory.cpp#L27-74
Expand All @@ -15,7 +22,9 @@ def parse_file(file_content: TextIOBase) -> list[Fraction]:
file_content (TextIOBase): The timestamps content.
Returns:
A list of each frame timestamps (in milliseconds).
A tuple containing these 3 informations:
1. A list of each frame timestamps (in milliseconds).
2. The fps (if supported by the timestamps file format).
"""

regex_timestamps = compile("^# *time(?:code|stamp) *format v(\\d+).*")
Expand All @@ -26,14 +35,116 @@ def parse_file(file_content: TextIOBase) -> list[Fraction]:

version = int(match.group(1))

if version == 2 or version == 4:
if version == 1:
timestamps, fps = TimestampsFileParser._parse_v1_file(file_content)
elif version == 2 or version == 4:
timestamps = TimestampsFileParser._parse_v2_and_v4_file(file_content, version)
fps = None
else:
raise NotImplementedError(
f"The file uses version {version}, but this format is currently not supported."
)

return timestamps
return timestamps, fps


@staticmethod
def _parse_v1_file(file_content: TextIOBase) -> tuple[list[Fraction], Fraction]:
"""Create timestamps based on the timestamps v1 file provided.
Inspired by: https://gitlab.com/mbunkus/mkvtoolnix/-/blob/72dfe260effcbd0e7d7cf6998c12bb35308c004f/src/merge/timestamp_factory.cpp#L82-175
Parameters:
file_content (TextIOBase): The timestamps content
Returns:
A tuple containing these 2 informations:
1. A list of each frame timestamps (in milliseconds).
2. The fps.
"""
timestamps: list[Fraction] = []
ranges_v1: list[RangeV1] = []
line: str = ""

for line in file_content:
if not line:
raise ValueError(
f"The timestamps file does not contain a valid 'Assume' line with the default number of frames per second."
)
line = line.strip(" \t")

if line and not line.startswith("#"):
break

if not line.lower().startswith("assume "):
raise ValueError(
f"The timestamps file does not contain a valid 'Assume' line with the default number of frames per second."
)

line = line[7:].strip(" \t")
try:
default_fps = Fraction(line)
except ValueError:
raise ValueError(
f"The timestamps file does not contain a valid 'Assume' line with the default number of frames per second."
)

for line in file_content:
line = line.strip(" \t\n\r")

if not line or line.startswith("#"):
continue

line_splitted = line.split(",")
if len(line_splitted) != 3:
raise ValueError(
f'The timestamps file contain a invalid line. Here is it: "{line}"'
)
try:
start_frame = int(line_splitted[0])
end_frame = int(line_splitted[1])
fps = Fraction(line_splitted[2])
except ValueError:
raise ValueError(
f'The timestamps file contain a invalid line. Here is it: "{line}"'
)

range_v1 = RangeV1(start_frame, end_frame, fps)

if range_v1.start_frame < 0 or range_v1.end_frame < 0:
raise ValueError("Cannot specify frame rate for negative frames.")
if range_v1.end_frame < range_v1.start_frame:
raise ValueError(
"End frame must be greater than or equal to start frame."
)
if range_v1.fps <= 0:
raise ValueError("FPS must be greater than zero.")
elif range_v1.fps == 0:
# mkvmerge allow fps to 0, but we can ignore them, since they won't impact the timestamps
continue

ranges_v1.append(range_v1)

ranges_v1.sort(key=lambda x: x.start_frame)

time: Fraction = Fraction(0)
frame: int = 0
for range_v1 in ranges_v1:
if frame > range_v1.start_frame:
raise ValueError("Override ranges must not overlap.")

while frame < range_v1.start_frame:
timestamps.append(time)
time += Fraction(1000) / default_fps
frame += 1

while frame <= range_v1.end_frame:
timestamps.append(time)
time += Fraction(1000) / range_v1.fps
frame += 1

timestamps.append(time)
return timestamps, default_fps


@staticmethod
Expand Down

0 comments on commit 6af8407

Please sign in to comment.