"""Tempo em fração racional — TimeValue e o Timecode que o embrulha. Extraído de models.py — ver fcpxml/models/__init__.py. """ import operator from dataclasses import dataclass from fractions import Fraction from functools import total_ordering from math import gcd from typing import Callable # Standard FCPXML timebase denominators that FCP's DTD validator accepts. # TimeValue.to_fcpxml() only simplifies fractions when the result uses one # of these denominators, preventing values like "8/3s" that FCP rejects. _FCPXML_STANDARD_TIMEBASES = frozenset({ 1, 24, 25, 30, 48, 50, 60, 90, 96, 100, 120, 240, 600, 2400, 4800, 9600, 48000, }) @total_ordering @dataclass class TimeValue: """ Represents time in FCPXML's rational format. FCPXML uses fractions of seconds (e.g., "90/30s" for 3 seconds at 30fps). This class handles conversion between timecode, seconds, and FCPXML format. Examples: TimeValue(90, 30) # 3 seconds at 30fps TimeValue(1, 1) # 1 second TimeValue.from_timecode("00:01:30:15", fps=30) # 90.5 seconds """ numerator: int denominator: int = 1 def __post_init__(self): if self.denominator == 0: raise ValueError( f"TimeValue denominator cannot be zero (got {self.numerator}/0). " "This would corrupt all downstream time calculations." ) # Normalize sign: denominator must always be positive. # Cross-multiplication in __lt__/__eq__ assumes positive denominators; # __hash__ assumes canonical form. Without this, TimeValue(1, -2) # compares/hashes incorrectly against TimeValue(-1, 2). if self.denominator < 0: # Use object.__setattr__ because dataclass may be frozen-like object.__setattr__(self, 'numerator', -self.numerator) object.__setattr__(self, 'denominator', -self.denominator) @classmethod def from_timecode(cls, tc: str, fps: float = 30.0) -> 'TimeValue': """ Create TimeValue from various string formats. Supported formats: - "HH:MM:SS:FF" - Standard timecode - "HH:MM:SS;FF" - Drop-frame timecode - "30s" - Seconds - "90/30s" - FCPXML rational format - "15f" - Frames """ if not tc: return cls(0, 1) tc = str(tc).strip() # FCPXML format: "90/30s" or "30s" if tc.endswith('s'): tc_val = tc[:-1] if '/' in tc_val: parts = tc_val.split('/', 1) num, denom = int(parts[0]), int(parts[1]) if denom == 0: raise ValueError(f"Zero denominator in timecode: {tc}") return cls(num, denom) else: seconds = float(tc_val) frames = int(round(seconds * fps)) # int(fps) truncates NTSC rates (23.976/29.97/59.94fps) to # their nominal integer, mismatching the numerator (computed # with the real fps) against the denominator — e.g. at # 23.976fps this silently produced values ~1.04x too large. # Reconstruct the exact rational fps (24000/1001, etc.) from # the float instead, so numerator and denominator agree. fps_frac = Fraction(fps).limit_denominator(100_000) return cls(frames * fps_frac.denominator, fps_frac.numerator) # Frame format: "15f" if tc.endswith('f'): frames = int(tc[:-1]) return cls(frames, int(fps)) # Timecode format: "HH:MM:SS:FF" or "HH:MM:SS;FF" if ':' in tc or ';' in tc: parts = tc.replace(';', ':').split(':') if len(parts) == 4: h, m, s, f = map(int, parts) total_frames = int((h * 3600 + m * 60 + s) * fps + f) return cls(total_frames, int(fps)) elif len(parts) == 3: h, m, s = map(int, parts) total_frames = int((h * 3600 + m * 60 + s) * fps) return cls(total_frames, int(fps)) # Try as plain number (seconds) try: seconds = float(tc) frames = int(round(seconds * fps)) return cls(frames, int(fps)) except ValueError: raise ValueError(f"Invalid timecode format: {tc}") @classmethod def from_seconds(cls, seconds: float, fps: float = 30.0) -> 'TimeValue': """Create TimeValue from decimal seconds.""" frames = int(round(seconds * fps)) return cls(frames, int(fps)) @classmethod def zero(cls) -> 'TimeValue': """Return zero time value.""" return cls(0, 1) def to_fcpxml(self) -> str: """Convert to FCPXML time string (e.g., "90/30s"). Only simplifies when the denominator reduces to 1 (whole seconds) or stays a standard FCPXML timebase. Avoids producing denominators like 3, 7, etc. that FCP's DTD validator may reject. """ simplified = self.simplify() if simplified.denominator == 1: return f"{simplified.numerator}s" # Keep original denominator if simplification produces a non-standard # denominator (not a multiple of common timebases: 24, 30, 25, 2400) if simplified.denominator in _FCPXML_STANDARD_TIMEBASES: return f"{simplified.numerator}/{simplified.denominator}s" # Fall back to unsimplified form return f"{self.numerator}/{self.denominator}s" def to_seconds(self) -> float: """Convert to decimal seconds.""" return self.numerator / self.denominator def to_timecode(self, fps: float = 30.0) -> str: """Convert to HH:MM:SS:FF timecode string.""" total_frames = int(round(self.to_seconds() * fps)) total_secs, frames = divmod(total_frames, int(fps)) total_mins, secs = divmod(total_secs, 60) hours, mins = divmod(total_mins, 60) return f"{hours:02d}:{mins:02d}:{secs:02d}:{frames:02d}" def to_frames(self, fps: float = 30.0) -> int: """Convert to frame count.""" return int(round(self.to_seconds() * fps)) def simplify(self) -> 'TimeValue': """Reduce fraction to simplest form.""" if self.numerator == 0: return TimeValue(0, 1) divisor = gcd(abs(self.numerator), abs(self.denominator)) return TimeValue( self.numerator // divisor, self.denominator // divisor ) @staticmethod def _lcm_denom(d1: int, d2: int) -> int: """LCM of two denominators for cross-timebase arithmetic.""" return d1 // gcd(d1, d2) * d2 def _binop(self, other: 'TimeValue', op: Callable[[int, int], int]) -> 'TimeValue': """Shared logic for add/sub: same-denom fast path, then LCM alignment.""" if self.denominator == other.denominator: return TimeValue(op(self.numerator, other.numerator), self.denominator) lcd = TimeValue._lcm_denom(self.denominator, other.denominator) return TimeValue( op( self.numerator * (lcd // self.denominator), other.numerator * (lcd // other.denominator), ), lcd, ) def __add__(self, other: 'TimeValue') -> 'TimeValue': return self._binop(other, operator.add) def __sub__(self, other: 'TimeValue') -> 'TimeValue': return self._binop(other, operator.sub) def __mul__(self, scalar: float) -> 'TimeValue': new_num = round(self.numerator * scalar) return TimeValue(new_num, self.denominator) def __truediv__(self, scalar: float) -> 'TimeValue': if scalar == 0: raise ZeroDivisionError("Cannot divide TimeValue by zero") new_denom = round(self.denominator * scalar) if new_denom == 0: raise ZeroDivisionError( f"Division by {scalar} rounds denominator {self.denominator} to zero" ) return TimeValue(self.numerator, new_denom) def __lt__(self, other: 'TimeValue') -> bool: # Cross-multiply to compare without float conversion: # a/b < c/d ↔ a*d < c*b (denominators are always positive) return self.numerator * other.denominator < other.numerator * self.denominator def __eq__(self, other: object) -> bool: if not isinstance(other, TimeValue): return False # Cross-multiply for exact integer comparison return self.numerator * other.denominator == other.numerator * self.denominator def __hash__(self) -> int: # Delegate to simplify() — single source of truth for canonical form. # __post_init__ guarantees denominator > 0, so no zero guard needed. s = self.simplify() return hash((s.numerator, s.denominator)) def snap_to_frame(self, fps: float) -> 'TimeValue': """Round this time value to the nearest frame boundary at the given fps. Uses the 2400-tick timebase (LCM of common frame rates) so results always land on clean frame boundaries. Args: fps: Frame rate to snap to (e.g. 24, 30, 60) Returns: New TimeValue snapped to the nearest frame in 2400-tick timebase. """ fps_int = int(fps) if fps_int <= 0: raise ValueError(f"fps must be positive, got {fps}") ticks_per_frame = 2400 // fps_int total_ticks = round(self.to_seconds() * 2400) snapped_ticks = round(total_ticks / ticks_per_frame) * ticks_per_frame return TimeValue(snapped_ticks, 2400) def is_standard_timebase(self) -> bool: """Check if this TimeValue's denominator is an FCP-accepted timebase.""" simplified = self.simplify() return simplified.denominator in _FCPXML_STANDARD_TIMEBASES def __repr__(self) -> str: return f"TimeValue({self.numerator}/{self.denominator}s = {self.to_seconds():.3f}s)" @dataclass class Timecode: """ Represents a timecode value. Note: This class exists for backwards compatibility with the parser. New code should prefer TimeValue for rational time math. """ frames: int frame_rate: float = 24.0 drop_frame: bool = False @property def seconds(self) -> float: return self.frames / self.frame_rate @property def total_frames(self) -> int: return self.frames def to_smpte(self) -> str: """Convert to SMPTE timecode string (HH:MM:SS:FF).""" total_seconds = int(self.seconds) hours = total_seconds // 3600 minutes = (total_seconds % 3600) // 60 secs = total_seconds % 60 frames = int((self.seconds - total_seconds) * self.frame_rate) separator = ";" if self.drop_frame else ":" return f"{hours:02d}:{minutes:02d}:{secs:02d}{separator}{frames:02d}" @classmethod def from_rational(cls, rational_str: str, frame_rate: float = 24.0) -> "Timecode": """Parse FCPXML rational time format (e.g., '3600/24s').""" if not rational_str: return cls(frames=0, frame_rate=frame_rate) if rational_str.endswith('s'): rational_str = rational_str[:-1] if '/' in rational_str: num, denom = rational_str.split('/') seconds = int(num) / int(denom) else: seconds = float(rational_str) frames = int(seconds * frame_rate) return cls(frames=frames, frame_rate=frame_rate) def to_rational(self) -> str: """Convert to FCPXML rational format.""" return f"{self.frames}/{int(self.frame_rate)}s" def to_time_value(self) -> TimeValue: """Convert to TimeValue for rational math.""" return TimeValue(self.frames, int(self.frame_rate))