diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b13fc2..920308e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.0.0b1] - 2025-10-24 +## [1.0.0b3] + +### Fixed + +- Fix Python 3.14 compatibility issue in `move_frame()` method where `Fraction()` constructor with two arguments failed due to stricter type requirements + + 修复 Python 3.14 兼容性问题,`move_frame()` 方法中 `Fraction()` 构造函数因更严格的类型要求而失败 + +- Fix precision loss issues in timecode initialization methods (`__init_smpte()`, `__init_frame()`) by using `Fraction()` division instead of float division + + 修复时码初始化方法中的精度损失问题,使用 `Fraction()` 除法替代浮点数除法 + +- Fix FCPX format output to correctly handle integer timestamps, avoiding large numerator values when the result is a whole number + + 修复 FCPX 格式输出,正确处理整数时间戳,避免结果为整数时出现大分子值 + +### Added + +- Add `move_frame()` and `move_time()` methods to DfttTimecode class for moving timecode by specified frames or time duration + + 为 DfttTimecode 类添加 `move_frame()` 和 `move_time()` 方法,用于按指定帧数或时间段移动时码 + +- Add comprehensive unit tests (32 tests) for `move_frame()` and `move_time()` methods + + 为 `move_frame()` 和 `move_time()` 方法添加全面的单元测试(32 个测试) + + - Frame movement tests: forward/backward movement, zero movement, large movements (86400 frames) + + 帧移动测试:正向/反向移动、零移动、大规模移动(86400 帧) + + - Time movement tests: float/int/Fraction input support, various time movements + + 时间移动测试:浮点数/整数/分数输入支持、各种时间移动 + + - Strict mode tests: 24-hour cycling behavior verification + + 严格模式测试:24 小时循环行为验证 + + - Different frame rates: 24, 30, 60, 119.88 fps + + 不同帧率:24、30、60、119.88 fps + + - Drop-frame timecode support tests + + 丢帧时码支持测试 + + - Method chaining and input validation tests + + 方法链和输入验证测试 + + - Equivalence test between `move_frame()` and `move_time()` + + `move_frame()` 和 `move_time()` 的等价性测试 + +### Changed + +- Improved internal timestamp precision across the entire codebase by consistently using `Fraction()` arithmetic + + 通过始终使用 `Fraction()` 算术运算,提高整个代码库的内部时间戳精度 + +## [1.0.0b2] +### Fixed +- Fix bug for fcpx output format + 修复 fcpx 输出格式的错误 + +## [1.0.0b1] ### Summary diff --git a/dftt_timecode/__init__.py b/dftt_timecode/__init__.py index 7a58ef6..8ad7a94 100644 --- a/dftt_timecode/__init__.py +++ b/dftt_timecode/__init__.py @@ -25,13 +25,16 @@ 01:00:04:04 """ -from dftt_timecode.core.dftt_timecode import DfttTimecode +from fractions import Fraction +from typing import Optional +from dftt_timecode.core.dftt_timecode import DfttTimecode, TimecodeType from dftt_timecode.core.dftt_timerange import DfttTimeRange from dftt_timecode.logging_config import configure_logging, get_logger # Read version from package metadata (populated from pyproject.toml) try: from importlib.metadata import version, PackageNotFoundError + try: __version__ = version("dftt-timecode") except PackageNotFoundError: @@ -43,7 +46,13 @@ # Aliases for easier importing -def timecode(*args, **kwargs) -> DfttTimecode: +def Timecode( + timecode_value, + timecode_type: TimecodeType = "auto", + fps=24.0, + drop_frame=None, + strict=True, +) -> DfttTimecode: """Create a DfttTimecode instance. This is an alias for :class:`DfttTimecode` constructor. @@ -56,12 +65,26 @@ def timecode(*args, **kwargs) -> DfttTimecode: DfttTimecode: A new timecode instance Example: - >>> tc = timecode('01:00:00:00', fps=24) + >>> tc = Timecode('01:00:00:00', fps=24) """ - return DfttTimecode(*args, **kwargs) - - -def timerange(*args, **kwargs) -> DfttTimeRange: + return DfttTimecode( + timecode_value, + timecode_type=timecode_type, + fps=fps, + drop_frame=drop_frame, + strict=strict, + ) + + +def Timerange( + start_tc=None, + end_tc=None, + forward: bool = True, + fps=24.0, + start_precise_time: Optional[Fraction] = None, + precise_duration: Optional[Fraction] = None, + strict_24h: bool = False, +) -> DfttTimeRange: """Create a DfttTimeRange instance. This is an alias for :class:`DfttTimeRange` constructor. @@ -74,12 +97,26 @@ def timerange(*args, **kwargs) -> DfttTimeRange: DfttTimeRange: A new timerange instance Example: - >>> tr = timerange('01:00:00:00', '02:00:00:00', fps=24) + >>> tr = Timerange('01:00:00:00', '02:00:00:00', fps=24) """ - return DfttTimeRange(*args, **kwargs) - - -def dtc(*args, **kwargs) -> DfttTimecode: + return DfttTimeRange( + start_tc=start_tc, + end_tc=end_tc, + fps=fps, + forward=forward, + start_precise_time=start_precise_time, + precise_duration=precise_duration, + strict_24h=strict_24h, + ) + + +def dtc( + timecode_value, + timecode_type: TimecodeType = "auto", + fps=24.0, + drop_frame=None, + strict=True, +) -> DfttTimecode: """Create a DfttTimecode instance (short alias). This is a short alias for :class:`DfttTimecode` constructor. @@ -94,10 +131,24 @@ def dtc(*args, **kwargs) -> DfttTimecode: Example: >>> tc = dtc('01:00:00:00', fps=24) """ - return DfttTimecode(*args, **kwargs) - - -def dtr(*args, **kwargs) -> DfttTimeRange: + return DfttTimecode( + timecode_value, + timecode_type=timecode_type, + fps=fps, + drop_frame=drop_frame, + strict=strict, + ) + + +def dtr( + start_tc=None, + end_tc=None, + forward: bool = True, + fps=24.0, + start_precise_time: Optional[Fraction] = None, + precise_duration: Optional[Fraction] = None, + strict_24h: bool = False, +) -> DfttTimeRange: """Create a DfttTimeRange instance (short alias). This is a short alias for :class:`DfttTimeRange` constructor. @@ -112,7 +163,16 @@ def dtr(*args, **kwargs) -> DfttTimeRange: Example: >>> tr = dtr('01:00:00:00', '02:00:00:00', fps=24) """ - return DfttTimeRange(*args, **kwargs) + return DfttTimeRange( + start_tc=start_tc, + end_tc=end_tc, + fps=fps, + forward=forward, + start_precise_time=start_precise_time, + precise_duration=precise_duration, + strict_24h=strict_24h, + ) + name = "dftt_timecode" __author__ = "You Ziyuan" diff --git a/dftt_timecode/core/dftt_timecode.py b/dftt_timecode/core/dftt_timecode.py index 642b2a0..cbf2bc0 100644 --- a/dftt_timecode/core/dftt_timecode.py +++ b/dftt_timecode/core/dftt_timecode.py @@ -228,8 +228,7 @@ def __init_smpte(self, timecode_value: str,minus_flag:bool): if minus_flag: frame_index = -frame_index - self.__precise_time = Fraction( - frame_index / self.__fps) # 时间戳=帧号/帧率 + self.__precise_time = Fraction(frame_index) / Fraction(self.__fps) # 时间戳=帧号/帧率 def __init_srt(self, timecode_value: str,minus_flag:bool): if not SRT_REGEX.match(timecode_value): # 判断输入是否符合SRT类型 @@ -305,8 +304,7 @@ def __init_frame(self, timecode_value: str,minus_flag:bool): self.__nominal_fps * 86400) else: pass - self.__precise_time = Fraction( - temp_frame_index / self.__fps) # 转换为内部精准时间戳 + self.__precise_time = Fraction(temp_frame_index) / Fraction(self.__fps) # 转换为内部精准时间戳 def __init_time(self, timecode_value: str,minus_flag:bool): if not TIME_REGEX.match(timecode_value): @@ -548,6 +546,103 @@ def precise_timestamp(self) -> Fraction: Fraction(1, 1) """ return self.__precise_time + + @property + def smpte(self) -> str: + """Get the SMPTE timecode string representation. + + Returns: + str: The SMPTE timecode string (e.g., '01:23:45:12' or '01:23:45;12' for drop-frame) + + Example: + >>> tc = DfttTimecode('01:00:00:00', fps=24) + >>> tc.smpte + '01:00:00:00' + """ + return self._convert_to_output_smpte() + + @property + def srt(self) -> str: + """Get the SRT timecode string representation. + + Returns: + str: The SRT timecode string (e.g., '01:23:45,678') + + Example: + >>> tc = DfttTimecode('01:00:00:00', fps=24) + >>> tc.srt + '01:00:00,000' + """ + return self._convert_to_output_srt() + + @property + def dlp(self) -> str: + """Get the DLP timecode string representation. + + Returns: + str: The DLP timecode string (e.g., '01:23:45:102') + + Example: + >>> tc = DfttTimecode('01:00:00:00', fps=24) + >>> tc.dlp + '01:00:00:000' + """ + return self._convert_to_output_dlp() + + @property + def ffmpeg(self) -> str: + """Get the FFmpeg timecode string representation. + + Returns: + str: The FFmpeg timecode string (e.g., '01:23:45.67') + + Example: + >>> tc = DfttTimecode('01:00:00:00', fps=24) + >>> tc.ffmpeg + '01:00:00.00' + """ + return self._convert_to_output_ffmpeg() + + @property + def fcpx(self) -> str: + """Get the Final Cut Pro X timecode string representation. + + Returns: + str: The FCPX timecode string (e.g., '1234/24s') + + Example: + >>> tc = DfttTimecode('01:00:00:00', fps=24) + >>> tc.fcpx + '86400/24s' + """ + return self._convert_to_output_fcpx() + + @property + def frame(self) -> int: + """Get the frame count string representation. + + Returns: + int: The frame count (e.g., 1234) + + Example: + >>> tc = DfttTimecode('01:00:00:00', fps=24) + >>> tc.frame + 86400 + """ + return self._convert_to_output_frame() + + @property + def time(self) -> float: + """Get the timestamp string representation in seconds. + + Returns: + float: The timestamp in seconds (e.g., 1234.5) + Example: + >>> tc = DfttTimecode('01:00:00:00', fps=24) + >>> tc.time + 3600.0 + """ + return self._convert_to_output_time() def _convert_to_output_smpte(self, output_part=0) -> str: minus_flag = False @@ -684,25 +779,25 @@ def _convert_to_output_fcpx(self, output_part=0) -> str: else: logger.warning( '_convert_to_output_fcpx: This timecode type has only one part.') - output_fcpx_denominator='' if float(self.__precise_time).is_integer() else f'/{self.__precise_time.denominator}' - return f'{self.__precise_time.numerator}{output_fcpx_denominator}s' - - def _convert_to_output_frame(self, output_part=0) -> str: - if output_part == 0: - pass + if float(self.__precise_time).is_integer(): + return f'{int(self.__precise_time)}s' else: + return f'{self.__precise_time.numerator}/{self.__precise_time.denominator}s' + + def _convert_to_output_frame(self, output_part=0) -> int: + if output_part != 0: logger.warning( 'This timecode type [frame] has only one part.') - return str(round(self.__precise_time * self.__fps)) + return round(self.__precise_time * self.__fps) - def _convert_to_output_time(self, output_part=0) -> str: + def _convert_to_output_time(self, output_part=0) -> float: if output_part == 0: pass else: logger.warning( 'This timecode type [time] has only one part.') output_time = round(float(self.__precise_time), 5) - return str(output_time) + return output_time def timecode_output(self, dest_type: TimecodeType = 'auto', output_part: int = 0) -> str: """Convert timecode to specified format and return as string. @@ -749,7 +844,7 @@ def timecode_output(self, dest_type: TimecodeType = 'auto', output_part: int = 0 # Call the conversion method try: func = getattr(self, method_name) - return func(output_part) + return str(func(output_part)) except Exception as e: # If the conversion method fails, log the error and fall back to SMPTE logger.error( @@ -883,6 +978,42 @@ def get_audio_sample_count(self, sample_rate: int) -> int: numerator,denominator=self.__precise_time.as_integer_ratio() return floor(numerator * sample_rate/denominator) + def move_frame(self, frames: int) -> 'DfttTimecode': + """Move the timecode by a certain number of frames. + Args: + frames: Number of frames to move. Positive to move forward, negative to move backward. + Returns: + DfttTimecode: Self reference for method chaining + """ + if not isinstance(frames, int): + logger.error('Frames parameter must be an integer.') + raise DFTTTimecodeOperatorError + + new_precise_time = self.__precise_time + Fraction(frames) / Fraction(self.__fps) + + self.__precise_time = new_precise_time + self.__apply_strict() + + return self + + def move_time(self, seconds: Union[float, Fraction,int]) -> 'DfttTimecode': + """Move the timecode by a certain number of seconds. + Args: + seconds: Number of seconds to move. Positive to move forward, negative to move backward. + Returns: + DfttTimecode: Self reference for method chaining + """ + if not isinstance(seconds, (float, Fraction,int)): + logger.error('Seconds parameter must be a float or Fraction.') + raise DFTTTimecodeOperatorError + + new_precise_time = self.__precise_time + Fraction(seconds) + + self.__precise_time = new_precise_time + self.__apply_strict() + + return self + def __repr__(self) -> str: """Return detailed string representation of the timecode object. diff --git a/pyproject.toml b/pyproject.toml index 63016c1..330eab8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dftt-timecode" -version = "1.0.0b1" +version = "1.0.0b3" description = "Timecode library for film and TV industry, supports HFR and a bunch of cool features" readme = "README.md" requires-python = ">=3.11" diff --git a/test/test_dftt_timecode.py b/test/test_dftt_timecode.py index 9628c20..9752833 100644 --- a/test/test_dftt_timecode.py +++ b/test/test_dftt_timecode.py @@ -63,7 +63,7 @@ def test_invalid_timecode(timecode_value, timecode_type, fps, drop_frame, strict from dftt_timecode.error import DFTTTimecodeValueError with pytest.raises(DFTTTimecodeValueError): - tc = TC(timecode_value, timecode_type, fps, drop_frame, strict) + TC(timecode_value, timecode_type, fps, drop_frame, strict) @pytest.mark.parametrize( @@ -121,10 +121,10 @@ def test_dropframe_strict(timecode_value, timecode_type, fps, drop_frame, strict @pytest.fixture( params=[ - ("00:01:01:01", "auto", 24, False, True, 61.04167, Fraction(1465 / 24)), - ("1000f", "auto", 119.88, True, True, 8.34168, Fraction(1000 / 119.88)), + ("00:01:01:01", "auto", 24, False, True, 61.04167, Fraction(1465, 24)), + ("1000f", "auto", 119.88, True, True, 8.34168, Fraction(1000, Fraction(119.88))), ("1.0s", "auto", Fraction(60000 / 1001), True, True, 1, 1), - ("00:01:00;02", "auto", 29.97, True, True, 60.06006, Fraction(1800 / 29.97)), + ("00:01:00;02", "auto", 29.97, True, True, 60.06006, Fraction(1800, Fraction(29.97))), ], ids=["smpte", "frame", "time", "smpte_nf"], ) @@ -716,7 +716,7 @@ def test_mul_xfail(): tc_1 = TC("00:00:00:23", "auto", 24, False, True) tc_2 = TC("00:11:45:14", "auto", 24, False, True) with pytest.raises(DFTTTimecodeOperatorError): - tc_mul_xfail = tc_1 * tc_2 + tc_1 * tc_2 @pytest.fixture( @@ -1057,7 +1057,7 @@ def test_lt(tc_value, compare_value, xvalue): "srt_float", ], ) -def test_lt(tc_value, compare_value, xvalue): +def test_le(tc_value, compare_value, xvalue): tc = TC(*tc_value) from numbers import Number @@ -1112,3 +1112,188 @@ def test_int(tc_value, xvalue): def test_audio_sample_count(tc_value, sample_rate, xvalue): tc = TC(*tc_value) assert tc.get_audio_sample_count(sample_rate) == xvalue + + +# Tests for move_frame method +@pytest.mark.parametrize( + argnames="tc_value,frames,expected_tc", + argvalues=[ + # Move forward + (("00:00:00:00", "auto", 24, False, True), 24, "00:00:01:00"), + (("00:00:00:00", "auto", 24, False, True), 100, "00:00:04:04"), + # Move backward + (("00:00:10:00", "auto", 24, False, True), -24, "00:00:09:00"), + (("00:00:10:00", "auto", 24, False, True), -100, "00:00:05:20"), + # Move by zero + (("00:00:05:12", "auto", 24, False, True), 0, "00:00:05:12"), + # Large movement (86400 frames at 24fps = 1 hour) + (("00:00:00:00", "auto", 24, False, True), 86400, "01:00:00:00"), + # Different frame rates + (("00:00:00:00", "auto", 30, False, True), 30, "00:00:01:00"), + (("00:00:00:00", "auto", 60, False, True), 60, "00:00:01:00"), + # High frame rate + (("00:00:00:00", "auto", 119.88, False, True), 119, "00:00:00:119"), + # Drop frame + (("00:00:00;00", "auto", 29.97, True, True), 30, "00:00:01;00"), + ], + ids=[ + "forward_1sec_24fps", + "forward_100frames_24fps", + "backward_1sec_24fps", + "backward_100frames_24fps", + "zero_movement", + "large_movement_86400frames", + "forward_1sec_30fps", + "forward_1sec_60fps", + "high_fps_119.88", + "drop_frame_29.97", + ], +) +def test_move_frame(tc_value, frames, expected_tc): + """Test move_frame method with various frame movements.""" + tc = TC(*tc_value) + tc.move_frame(frames) + assert tc.timecode_output() == expected_tc + + +@pytest.mark.parametrize( + argnames="tc_value,frames,expected_framecount", + argvalues=[ + # Verify frame count after movement + (("00:00:00:00", "auto", 24, False, True), 100, 100), + (("00:00:10:00", "auto", 24, False, True), -100, 140), + (("00:00:00:00", "auto", 24, False, True), 86400, 86400), + ], + ids=["forward_framecount", "backward_framecount", "large_framecount"], +) +def test_move_frame_framecount(tc_value, frames, expected_framecount): + """Test that move_frame correctly updates framecount property.""" + tc = TC(*tc_value) + tc.move_frame(frames) + assert tc.framecount == expected_framecount + + +def test_move_frame_strict_mode(): + """Test move_frame with strict mode (24-hour cycling).""" + tc = TC("23:59:59:23", "auto", fps=24, drop_frame=False, strict=True) + tc.move_frame(1) # Should cycle to 00:00:00:00 + assert tc.timecode_output() == "00:00:00:00" + + +def test_move_frame_chaining(): + """Test that move_frame returns self for method chaining.""" + tc = TC("00:00:00:00", "auto", fps=24, drop_frame=False, strict=True) + result = tc.move_frame(100).move_frame(50) + assert result is tc + assert tc.framecount == 150 + + +def test_move_frame_invalid_input(): + """Test move_frame raises error with non-integer input.""" + tc = TC("00:00:00:00", "auto", fps=24, drop_frame=False, strict=True) + with pytest.raises(DFTTTimecodeOperatorError): + tc.move_frame(10.5) # Float should raise error + with pytest.raises(DFTTTimecodeOperatorError): + tc.move_frame("100") # String should raise error + + +# Tests for move_time method +@pytest.mark.parametrize( + argnames="tc_value,seconds,expected_tc", + argvalues=[ + # Move forward with float + (("00:00:00:00", "auto", 24, False, True), 1.0, "00:00:01:00"), + (("00:00:00:00", "auto", 24, False, True), 10.5, "00:00:10:12"), + # Move backward with float + (("00:00:10:00", "auto", 24, False, True), -5.0, "00:00:05:00"), + # Move by zero + (("00:00:05:12", "auto", 24, False, True), 0.0, "00:00:05:12"), + # Move with integer seconds + (("00:00:00:00", "auto", 24, False, True), 60, "00:01:00:00"), + # Different frame rates + (("00:00:00:00", "auto", 30, False, True), 1.0, "00:00:01:00"), + (("00:00:00:00", "auto", 60, False, True), 2.5, "00:00:02:30"), + # Large movement (3600 seconds = 1 hour) + (("00:00:00:00", "auto", 24, False, True), 3600, "01:00:00:00"), + ], + ids=[ + "forward_1sec_float", + "forward_10.5sec", + "backward_5sec", + "zero_movement", + "forward_60sec_int", + "forward_1sec_30fps", + "forward_2.5sec_60fps", + "large_movement_3600sec", + ], +) +def test_move_time(tc_value, seconds, expected_tc): + """Test move_time method with various time movements.""" + tc = TC(*tc_value) + tc.move_time(seconds) + assert tc.timecode_output() == expected_tc + + +@pytest.mark.parametrize( + argnames="tc_value,seconds,expected_timestamp", + argvalues=[ + # Verify timestamp after movement + (("00:00:00:00", "auto", 24, False, True), 10.0, 10.0), + (("00:00:10:00", "auto", 24, False, True), -5.0, 5.0), + (("00:00:00:00", "auto", 24, False, True), 3600.0, 3600.0), + ], + ids=["forward_timestamp", "backward_timestamp", "large_timestamp"], +) +def test_move_time_timestamp(tc_value, seconds, expected_timestamp): + """Test that move_time correctly updates timestamp property.""" + tc = TC(*tc_value) + tc.move_time(seconds) + assert tc.timestamp == pytest.approx(expected_timestamp, rel=1e-6) + + +def test_move_time_with_fraction(): + """Test move_time with Fraction input for high precision.""" + tc = TC("00:00:00:00", "auto", fps=24, drop_frame=False, strict=True) + tc.move_time(Fraction(1, 3)) # Move by 1/3 second + # 1/3 second at 24fps = 8 frames + assert tc.framecount == 8 + assert tc.precise_timestamp == Fraction(1, 3) + + +def test_move_time_strict_mode(): + """Test move_time with strict mode (24-hour cycling).""" + tc = TC("23:59:59:23", "auto", fps=24, drop_frame=False, strict=True) + tc.move_time(Fraction(1, 24)) # Move by 1 frame duration, should cycle to 00:00:00:00 + assert tc.timecode_output() == "00:00:00:00" + + +def test_move_time_chaining(): + """Test that move_time returns self for method chaining.""" + tc = TC("00:00:00:00", "auto", fps=24, drop_frame=False, strict=True) + result = tc.move_time(1.0).move_time(2.5) + assert result is tc + assert tc.timestamp == pytest.approx(3.5, rel=1e-6) + + +def test_move_time_invalid_input(): + """Test move_time raises error with invalid input types.""" + tc = TC("00:00:00:00", "auto", fps=24, drop_frame=False, strict=True) + with pytest.raises(DFTTTimecodeOperatorError): + tc.move_time("10.5") # String should raise error + with pytest.raises(DFTTTimecodeOperatorError): + tc.move_time([10.5]) # List should raise error + + +# Combined test for move_frame and move_time equivalence +def test_move_frame_time_equivalence(): + """Test that move_frame and move_time produce equivalent results.""" + tc1 = TC("00:00:00:00", "auto", fps=24, drop_frame=False, strict=True) + tc2 = TC("00:00:00:00", "auto", fps=24, drop_frame=False, strict=True) + + # Moving 24 frames should equal moving 1 second at 24fps + tc1.move_frame(24) + tc2.move_time(1.0) + + assert tc1.framecount == tc2.framecount + assert tc1.timecode_output() == tc2.timecode_output() + assert tc1.timestamp == pytest.approx(tc2.timestamp, rel=1e-9) diff --git a/uv.lock b/uv.lock index 8323050..0af2b8a 100644 --- a/uv.lock +++ b/uv.lock @@ -150,7 +150,7 @@ wheels = [ [[package]] name = "dftt-timecode" -version = "0.0.15a2" +version = "1.0.0b3" source = { editable = "." } [package.dev-dependencies]