Skip to content

Commit

Permalink
Remove support for v1 timestamps file
Browse files Browse the repository at this point in the history
Currently, we don't correctly support v1 timestamps.
When the timestamps is over the last range, we have some rounding issue.

See the function "test_frame_to_time_v1" of the file tests\test_text_file_timestamps.py for an example.
  • Loading branch information
moi15moi committed Dec 16, 2024
1 parent f9f1b64 commit d36ae1d
Show file tree
Hide file tree
Showing 3 changed files with 7 additions and 234 deletions.
108 changes: 0 additions & 108 deletions tests/test_text_file_timestamps.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,114 +6,6 @@
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, approximate_pts_from_last_pts=True)

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, but since we round the pts, it give 567.
# Due to that, when we try to "guess" the time, there can be rounding issue.
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(634, 1000) # 1700/3 + 2/30 * 1000 = round(633.33) = 633 - Example of a rounding issue
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, approximate_pts_from_last_pts=True)

# 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
# precision
assert timestamps.time_to_frame(Fraction(600, 1000), TimeType.EXACT) == 12
assert timestamps.time_to_frame(Fraction(634, 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(633, TimeType.EXACT, 3) == 12
assert timestamps.time_to_frame(634, TimeType.EXACT, 3) == 13 # 1700/3 + 2/30 * 1000 = round(633.33) = 633, but rounding issue
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"
time_scale = Fraction(1000)
rounding_method = RoundingMethod.ROUND

timestamps = TextFileTimestamps(timestamps_str, time_scale, rounding_method, approximate_pts_from_last_pts=True)

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


def test_init_v2() -> None:
timestamps_str = (
"# timecode format v2\n"
Expand Down
12 changes: 2 additions & 10 deletions video_timestamps/text_file_timestamps.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,10 @@ 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, fps_from_file = TimestampsFileParser.parse_file(f)
timestamps = TimestampsFileParser.parse_file(f)
else:
f = StringIO(path_to_timestamps_file_or_content)
timestamps, fps_from_file = TimestampsFileParser.parse_file(f)

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
timestamps = TimestampsFileParser.parse_file(f)

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

Expand Down
121 changes: 5 additions & 116 deletions video_timestamps/timestamps_file_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,9 @@
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: TextIOWrapper) -> tuple[list[Fraction], Optional[Fraction]]:
def parse_file(file_content: TextIOWrapper) -> list[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 @@ -22,9 +15,7 @@ def parse_file(file_content: TextIOWrapper) -> tuple[list[Fraction], Optional[Fr
file_content (TextIOWrapper): The timestamps content.
Returns:
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).
A list of each frame timestamps (in milliseconds).
"""

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

version = int(match.group(1))

if version == 1:
timestamps, fps = TimestampsFileParser._parse_v1_file(file_content)
elif version == 2 or version == 4:
if 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, fps


@staticmethod
def _parse_v1_file(file_content: TextIOWrapper) -> 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 (TextIOWrapper): 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
return timestamps


@staticmethod
Expand All @@ -160,7 +49,7 @@ def _parse_v2_and_v4_file(
version (int): The version of the timestamps (only 2 or 4 is allowed)
Returns:
A list of each frame timestamps (in milliseconds).
A list of each frame timestamps (in milliseconds).
"""

if version not in (2, 4):
Expand Down

0 comments on commit d36ae1d

Please sign in to comment.