Files
gart/code/fcpxml/models/timing.py
T
João HenriqueandClaude Sonnet 5 d13f643ebc chore(fase0): higiene do repositório + corrige gitignore que escondia fcpxml/models/
Fase 0 do roteiro de reestruturação (Engine/docs/10_MAPA_REESTRUTURACAO.md):
move code/WHISPERX (2,6 GB de backups órfãos, sem uso ativo, sem
.gitmodules) para ~/Archives/G-ART-WHISPERX-backup fora do workspace git;
traz admin/ para o gate de lint de run_after_fix.sh; corrige
fcpxml/writer/adjustment.py, que gerava um wrapper <adjustment> inexistente
no DTD 1.13 (filtros agora vão direto no <clip>, na ordem exigida), com
teste de regressão novo.

Achado à parte: .gitignore tinha uma regra solta "models/" (pensada só
para o cache do Whisper em code/models/) que também escondia do git todo o
pacote fcpxml/models/ — nunca commitado, sem proteção nenhuma. Corrigida
para /code/models/, ancorada na raiz.

Docs atualizados no mesmo commit (02_MODULES, 09_MANUTENCAO,
10_MAPA_REESTRUTURACAO, 05_EXPERIENCIAS #34 e #36), conforme a regra do
CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 08:28:44 -04:00

305 lines
12 KiB
Python

"""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))