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>
305 lines
12 KiB
Python
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))
|