diff --git a/tests/test_text_file_timestamps.py b/tests/test_text_file_timestamps.py index 0cad920..624acb1 100644 --- a/tests/test_text_file_timestamps.py +++ b/tests/test_text_file_timestamps.py @@ -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" diff --git a/video_timestamps/text_file_timestamps.py b/video_timestamps/text_file_timestamps.py index 8865699..2eaded8 100644 --- a/video_timestamps/text_file_timestamps.py +++ b/video_timestamps/text_file_timestamps.py @@ -5,6 +5,7 @@ from io import StringIO from pathlib import Path from typing import Optional, Union +from warnings import warn __all__ = ["TextFileTimestamps"] @@ -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] diff --git a/video_timestamps/timestamps_file_parser.py b/video_timestamps/timestamps_file_parser.py index 348b632..7adb823 100644 --- a/video_timestamps/timestamps_file_parser.py +++ b/video_timestamps/timestamps_file_parser.py @@ -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 @@ -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+).*") @@ -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