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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0fdfe33613
commit
d13f643ebc
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Data models for Final Cut Pro FCPXML structures.
|
||||
|
||||
Provides a clean Python interface for working with Final Cut Pro timelines,
|
||||
clips, markers, and other elements.
|
||||
|
||||
Era um módulo de 1.091 linhas com seis famílias de modelo dentro. Agora cada
|
||||
família tem seu arquivo, e este pacote reexporta tudo — `from .models import
|
||||
TimeValue` segue valendo em todo o projeto, inclusive para os nomes com
|
||||
underscore que o writer e a suíte já usavam.
|
||||
|
||||
enums tipos e cores de marcador, transições, ritmo
|
||||
timing TimeValue (fração racional) e Timecode
|
||||
timeline clipes, marcadores, lanes, projeto
|
||||
planning rough cut, ritmo, montagem
|
||||
qc achados de QC e resultado de validação
|
||||
subtitles paleta e look das legendas dinâmicas
|
||||
"""
|
||||
|
||||
from .enums import (
|
||||
_MAX_MARKER_TYPE_LENGTH,
|
||||
MARKER_XML_TAGS,
|
||||
FlashFrameSeverity,
|
||||
MarkerColor,
|
||||
MarkerType,
|
||||
PacingCurve,
|
||||
PacingStyle,
|
||||
TransitionType,
|
||||
ValidationIssueType,
|
||||
)
|
||||
from .planning import (
|
||||
MontageConfig,
|
||||
PacingConfig,
|
||||
RoughCutResult,
|
||||
SegmentSpec,
|
||||
)
|
||||
from .qc import (
|
||||
DuplicateGroup,
|
||||
FlashFrame,
|
||||
GapInfo,
|
||||
ValidationIssue,
|
||||
ValidationResult,
|
||||
)
|
||||
from .subtitles import (
|
||||
COLOR_GREY,
|
||||
COLOR_INDIGO,
|
||||
COLOR_WHITE,
|
||||
COLOR_YELLOW,
|
||||
EDITORIAL_BODY_LOOK,
|
||||
EDITORIAL_EMPHASIS_LOOK,
|
||||
REFERENCE_RHYTHM,
|
||||
DynamicSubtitleConfig,
|
||||
SubtitlePosition,
|
||||
WordLook,
|
||||
WordStyle,
|
||||
)
|
||||
from .timeline import (
|
||||
AudioClip,
|
||||
Clip,
|
||||
CompoundClip,
|
||||
ConnectedClip,
|
||||
Keyword,
|
||||
Marker,
|
||||
Project,
|
||||
SilenceCandidate,
|
||||
Timeline,
|
||||
Transition,
|
||||
VideoClip,
|
||||
)
|
||||
from .timing import (
|
||||
_FCPXML_STANDARD_TIMEBASES,
|
||||
Timecode,
|
||||
TimeValue,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AudioClip",
|
||||
"COLOR_GREY",
|
||||
"COLOR_INDIGO",
|
||||
"COLOR_WHITE",
|
||||
"COLOR_YELLOW",
|
||||
"Clip",
|
||||
"CompoundClip",
|
||||
"ConnectedClip",
|
||||
"DuplicateGroup",
|
||||
"DynamicSubtitleConfig",
|
||||
"EDITORIAL_BODY_LOOK",
|
||||
"EDITORIAL_EMPHASIS_LOOK",
|
||||
"FlashFrame",
|
||||
"FlashFrameSeverity",
|
||||
"GapInfo",
|
||||
"Keyword",
|
||||
"MARKER_XML_TAGS",
|
||||
"Marker",
|
||||
"MarkerColor",
|
||||
"MarkerType",
|
||||
"MontageConfig",
|
||||
"PacingConfig",
|
||||
"PacingCurve",
|
||||
"PacingStyle",
|
||||
"Project",
|
||||
"REFERENCE_RHYTHM",
|
||||
"RoughCutResult",
|
||||
"SegmentSpec",
|
||||
"SilenceCandidate",
|
||||
"SubtitlePosition",
|
||||
"TimeValue",
|
||||
"Timecode",
|
||||
"Timeline",
|
||||
"Transition",
|
||||
"TransitionType",
|
||||
"ValidationIssue",
|
||||
"ValidationIssueType",
|
||||
"ValidationResult",
|
||||
"VideoClip",
|
||||
"WordLook",
|
||||
"WordStyle",
|
||||
"_FCPXML_STANDARD_TIMEBASES",
|
||||
"_MAX_MARKER_TYPE_LENGTH",
|
||||
]
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Enumerações do domínio: tipos e cores de marcador, transições, ritmo.
|
||||
|
||||
Extraído de models.py — ver fcpxml/models/__init__.py.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Maximum length for marker type strings to prevent memory abuse
|
||||
_MAX_MARKER_TYPE_LENGTH = 64
|
||||
|
||||
class MarkerType(Enum):
|
||||
"""Types of markers in Final Cut Pro.
|
||||
|
||||
Members:
|
||||
STANDARD — Default marker with no completion state.
|
||||
INCOMPLETE — Task marker (completed="0" in FCPXML). ← canonical name
|
||||
TODO — Alias for INCOMPLETE. Kept for backward compatibility;
|
||||
resolves to the same object (``MarkerType.TODO is
|
||||
MarkerType.INCOMPLETE``). Python enums treat the first
|
||||
member with a given value as canonical; all subsequent
|
||||
members sharing that value become aliases.
|
||||
CHAPTER — Chapter marker (``<chapter-marker>`` element).
|
||||
COMPLETED — Task marker with completed="1".
|
||||
|
||||
Serialization helpers:
|
||||
``from_string()`` — Accepts values, names, and legacy aliases
|
||||
(e.g. ``"todo-marker"``). Always returns the
|
||||
canonical member.
|
||||
``from_xml_element()`` — Reads an ``lxml``/``ElementTree`` element and
|
||||
returns the appropriate type based on the tag
|
||||
name and ``completed`` attribute.
|
||||
``xml_tag`` — The FCPXML element tag to emit when writing.
|
||||
``xml_attrs`` — Extra attributes required when writing (e.g.
|
||||
``completed="0"`` for INCOMPLETE).
|
||||
"""
|
||||
STANDARD = "standard"
|
||||
INCOMPLETE = "todo"
|
||||
TODO = "todo" # Backward-compat alias — resolves to INCOMPLETE at runtime
|
||||
CHAPTER = "chapter"
|
||||
COMPLETED = "completed"
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> 'MarkerType':
|
||||
"""Convert a string to MarkerType, accepting both enum names and values.
|
||||
|
||||
Includes input validation: rejects null bytes, control characters,
|
||||
and excessively long strings to prevent injection and memory abuse.
|
||||
|
||||
Examples:
|
||||
MarkerType.from_string("todo") -> MarkerType.INCOMPLETE
|
||||
MarkerType.from_string("TODO") -> MarkerType.INCOMPLETE
|
||||
MarkerType.from_string("completed") -> MarkerType.COMPLETED
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"Expected str, got {type(value).__name__}")
|
||||
if '\x00' in value or any(ord(c) < 32 and c not in ('\n', '\r', '\t') for c in value):
|
||||
raise ValueError("Marker type contains invalid control characters")
|
||||
if len(value) > _MAX_MARKER_TYPE_LENGTH:
|
||||
raise ValueError(
|
||||
f"Marker type exceeds maximum length ({_MAX_MARKER_TYPE_LENGTH} chars)"
|
||||
)
|
||||
lowered = value.strip().lower()
|
||||
if not lowered:
|
||||
raise ValueError("Marker type cannot be empty")
|
||||
# Accept legacy aliases from older specs (e.g. "todo-marker" → INCOMPLETE)
|
||||
aliases = {
|
||||
"todo-marker": "todo",
|
||||
"completed-marker": "completed",
|
||||
"chapter-marker": "chapter",
|
||||
}
|
||||
lowered = aliases.get(lowered, lowered)
|
||||
try:
|
||||
return cls(lowered)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid marker type: '{value}'. "
|
||||
f"Valid types: {', '.join(m.value for m in cls)}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_xml_element(cls, elem) -> 'MarkerType':
|
||||
"""Determine MarkerType from an XML element's tag and attributes.
|
||||
|
||||
Centralises the parse-side mapping so the parser doesn't need to
|
||||
know about completed-attribute semantics.
|
||||
|
||||
Rules (in priority order):
|
||||
1. <chapter-marker> tag → CHAPTER (completed attr ignored)
|
||||
2. completed='0' (exact) → INCOMPLETE
|
||||
3. completed='1' (exact) → COMPLETED
|
||||
4. Everything else → STANDARD (including whitespace-padded,
|
||||
absent, empty, or non-boolean completed values)
|
||||
|
||||
Matching is intentionally strict — no .strip(), no case folding.
|
||||
This prevents whitespace-injected attributes like ' 0 ' from
|
||||
being misclassified.
|
||||
"""
|
||||
if elem.tag == 'chapter-marker':
|
||||
return cls.CHAPTER
|
||||
completed = elem.get('completed')
|
||||
if completed == '0':
|
||||
return cls.INCOMPLETE
|
||||
if completed == '1':
|
||||
return cls.COMPLETED
|
||||
return cls.STANDARD
|
||||
|
||||
@property
|
||||
def xml_tag(self) -> str:
|
||||
"""Return the FCPXML element tag for this marker type."""
|
||||
return 'chapter-marker' if self == MarkerType.CHAPTER else 'marker'
|
||||
|
||||
@property
|
||||
def xml_attrs(self) -> dict:
|
||||
"""Return extra XML attributes this marker type requires when writing.
|
||||
|
||||
Centralises the write-side mapping so both FCPXMLModifier and
|
||||
FCPXMLWriter use a single source of truth.
|
||||
"""
|
||||
if self == MarkerType.CHAPTER:
|
||||
return {'posterOffset': '0s'}
|
||||
if self == MarkerType.INCOMPLETE:
|
||||
return {'completed': '0'}
|
||||
if self == MarkerType.COMPLETED:
|
||||
return {'completed': '1'}
|
||||
return {}
|
||||
|
||||
# Recognised marker XML tags — used by the parser for single-pass collection
|
||||
# and by the writer to validate element creation.
|
||||
MARKER_XML_TAGS = ('marker', 'chapter-marker')
|
||||
|
||||
class MarkerColor(Enum):
|
||||
"""Marker color options (FCP internal values)."""
|
||||
BLUE = 0
|
||||
CYAN = 1
|
||||
GREEN = 2
|
||||
YELLOW = 3
|
||||
ORANGE = 4
|
||||
RED = 5
|
||||
PINK = 6
|
||||
PURPLE = 7
|
||||
|
||||
class TransitionType(Enum):
|
||||
"""Built-in transition types."""
|
||||
CROSS_DISSOLVE = "Cross Dissolve"
|
||||
FADE_TO_BLACK = "Fade to Color"
|
||||
FADE_FROM_BLACK = "Fade from Color"
|
||||
DIP_TO_COLOR = "Dip to Color"
|
||||
WIPE = "Wipe"
|
||||
SLIDE = "Slide"
|
||||
|
||||
class PacingStyle(Enum):
|
||||
"""Pacing presets for rough cut generation."""
|
||||
SLOW = "slow" # 5-10 second cuts
|
||||
MEDIUM = "medium" # 2-5 second cuts
|
||||
FAST = "fast" # 0.5-2 second cuts
|
||||
DYNAMIC = "dynamic" # Varies throughout
|
||||
|
||||
class FlashFrameSeverity(Enum):
|
||||
"""Severity levels for flash frame detection."""
|
||||
CRITICAL = "critical" # < 2 frames, almost certainly an error
|
||||
WARNING = "warning" # < 6 frames, potentially intentional but suspicious
|
||||
|
||||
class PacingCurve(Enum):
|
||||
"""Pacing curves for montage generation."""
|
||||
CONSTANT = "constant" # Same clip duration throughout
|
||||
ACCELERATING = "accelerating" # Starts slow, gets faster
|
||||
DECELERATING = "decelerating" # Starts fast, gets slower
|
||||
PYRAMID = "pyramid" # Slow → fast → slow
|
||||
|
||||
class ValidationIssueType(Enum):
|
||||
"""Types of timeline validation issues."""
|
||||
FLASH_FRAME = "flash_frame"
|
||||
GAP = "gap"
|
||||
DUPLICATE = "duplicate"
|
||||
ORPHAN_REF = "orphan_ref"
|
||||
INVALID_OFFSET = "invalid_offset"
|
||||
# DTD validation types (v0.6.0)
|
||||
ELEMENT_ORDER = "element_order"
|
||||
MISSING_ATTRIBUTE = "missing_attribute"
|
||||
INVALID_TIMEBASE = "invalid_timebase"
|
||||
FRAME_MISALIGNMENT = "frame_misalignment"
|
||||
MISSING_EFFECT_REF = "missing_effect_ref"
|
||||
MISSING_MEDIA_REP = "missing_media_rep"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Especificações de geração: rough cut, ritmo e montagem.
|
||||
|
||||
Extraído de models.py — ver fcpxml/models/__init__.py.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from .enums import PacingCurve
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentSpec:
|
||||
"""Specification for a segment in auto rough cut."""
|
||||
name: str
|
||||
keywords: List[str] = field(default_factory=list)
|
||||
duration_seconds: float = 0.0
|
||||
priority: str = "best" # favorites, longest, shortest, random, best
|
||||
|
||||
@dataclass
|
||||
class PacingConfig:
|
||||
"""Configuration for rough cut pacing."""
|
||||
pacing: str = "medium" # slow, medium, fast, dynamic
|
||||
min_clip_duration: float = 1.0
|
||||
max_clip_duration: float = 8.0
|
||||
avg_clip_duration: Optional[float] = None
|
||||
vary_pacing: bool = True
|
||||
|
||||
def get_duration_range(self) -> Tuple[float, float]:
|
||||
"""Get min/max based on pacing style."""
|
||||
ranges = {
|
||||
"slow": (5.0, 10.0),
|
||||
"medium": (2.0, 5.0),
|
||||
"fast": (0.5, 2.0),
|
||||
"dynamic": (1.0, 6.0),
|
||||
}
|
||||
return ranges.get(self.pacing, (2.0, 5.0))
|
||||
|
||||
@dataclass
|
||||
class RoughCutResult:
|
||||
"""Result of auto rough cut generation."""
|
||||
output_path: str
|
||||
clips_used: int
|
||||
clips_available: int
|
||||
target_duration: float
|
||||
actual_duration: float
|
||||
segments: int
|
||||
average_clip_duration: float
|
||||
|
||||
@dataclass
|
||||
class MontageConfig:
|
||||
"""Configuration for montage generation with pacing curves."""
|
||||
target_duration: float # Target duration in seconds
|
||||
pacing_curve: 'PacingCurve'
|
||||
start_duration: float = 2.0 # Clip duration at start
|
||||
end_duration: float = 0.5 # Clip duration at end
|
||||
min_duration: float = 0.2 # Minimum allowed clip duration
|
||||
max_duration: float = 5.0 # Maximum allowed clip duration
|
||||
|
||||
def get_duration_at_position(self, position: float) -> float:
|
||||
"""
|
||||
Calculate clip duration for a given position (0.0 to 1.0).
|
||||
|
||||
Args:
|
||||
position: Position in montage (0.0 = start, 1.0 = end)
|
||||
|
||||
Returns:
|
||||
Target duration in seconds for a clip at this position
|
||||
"""
|
||||
if self.pacing_curve == PacingCurve.CONSTANT:
|
||||
duration = (self.start_duration + self.end_duration) / 2
|
||||
|
||||
elif self.pacing_curve == PacingCurve.ACCELERATING:
|
||||
# Linear interpolation from start to end duration
|
||||
duration = self.start_duration + (self.end_duration - self.start_duration) * position
|
||||
|
||||
elif self.pacing_curve == PacingCurve.DECELERATING:
|
||||
# Reverse: start fast, end slow
|
||||
duration = self.end_duration + (self.start_duration - self.end_duration) * position
|
||||
|
||||
elif self.pacing_curve == PacingCurve.PYRAMID:
|
||||
# Slow → fast → slow (parabolic curve)
|
||||
if position < 0.5:
|
||||
# First half: slow to fast
|
||||
duration = self.start_duration + (self.end_duration - self.start_duration) * (position * 2)
|
||||
else:
|
||||
# Second half: fast to slow
|
||||
duration = self.end_duration + (self.start_duration - self.end_duration) * ((position - 0.5) * 2)
|
||||
else:
|
||||
duration = self.start_duration
|
||||
|
||||
# Clamp to min/max
|
||||
return max(self.min_duration, min(self.max_duration, duration))
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Achados de QC e o resultado de uma validação.
|
||||
|
||||
Extraído de models.py — ver fcpxml/models/__init__.py.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .enums import FlashFrameSeverity, ValidationIssueType
|
||||
from .timing import Timecode
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlashFrame:
|
||||
"""
|
||||
Represents a detected flash frame (ultra-short clip).
|
||||
|
||||
Flash frames are typically editing errors - clips that are too short
|
||||
to be perceived as intentional cuts.
|
||||
"""
|
||||
clip_name: str
|
||||
clip_id: str
|
||||
start: Timecode
|
||||
duration_frames: int
|
||||
duration_seconds: float
|
||||
severity: 'FlashFrameSeverity'
|
||||
|
||||
@property
|
||||
def is_critical(self) -> bool:
|
||||
"""Check if this is a critical flash frame."""
|
||||
return self.severity == FlashFrameSeverity.CRITICAL
|
||||
|
||||
@dataclass
|
||||
class GapInfo:
|
||||
"""
|
||||
Represents a detected gap in the timeline.
|
||||
|
||||
Gaps can be intentional (black frames) or errors from deleted clips.
|
||||
"""
|
||||
start: Timecode
|
||||
duration_frames: int
|
||||
duration_seconds: float
|
||||
previous_clip: Optional[str] = None # Clip name before the gap
|
||||
next_clip: Optional[str] = None # Clip name after the gap
|
||||
|
||||
@property
|
||||
def timecode(self) -> str:
|
||||
"""Get timecode string for the gap start."""
|
||||
return self.start.to_smpte()
|
||||
|
||||
@dataclass
|
||||
class DuplicateGroup:
|
||||
"""
|
||||
Represents a group of clips using the same source media.
|
||||
|
||||
Useful for detecting duplicate clips that may be unintentional.
|
||||
"""
|
||||
source_ref: str # The asset/media reference ID
|
||||
source_name: str # Human-readable source name
|
||||
clips: List[Dict[str, Any]] = field(default_factory=list) # List of clip info dicts
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""Number of clips using this source."""
|
||||
return len(self.clips)
|
||||
|
||||
@property
|
||||
def has_overlapping_ranges(self) -> bool:
|
||||
"""Check if any clips use overlapping portions of the source."""
|
||||
# Sort clips by source_start
|
||||
sorted_clips = sorted(self.clips, key=lambda c: c.get('source_start', 0))
|
||||
for i in range(len(sorted_clips) - 1):
|
||||
curr_end = sorted_clips[i].get('source_start', 0) + sorted_clips[i].get('source_duration', 0)
|
||||
next_start = sorted_clips[i + 1].get('source_start', 0)
|
||||
if curr_end > next_start:
|
||||
return True
|
||||
return False
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""
|
||||
Represents a single validation issue found in a timeline.
|
||||
|
||||
Used by validate_timeline to report problems.
|
||||
"""
|
||||
issue_type: 'ValidationIssueType'
|
||||
severity: str # "error", "warning", "info"
|
||||
message: str
|
||||
timecode: Optional[str] = None
|
||||
clip_name: Optional[str] = None
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""
|
||||
Result of timeline validation.
|
||||
|
||||
Provides a health score and categorized list of issues.
|
||||
"""
|
||||
is_valid: bool
|
||||
health_score: int # 0-100 percentage
|
||||
issues: List[ValidationIssue] = field(default_factory=list)
|
||||
flash_frames: List[FlashFrame] = field(default_factory=list)
|
||||
gaps: List[GapInfo] = field(default_factory=list)
|
||||
duplicates: List[DuplicateGroup] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
return len([i for i in self.issues if i.severity == "error"])
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
return len([i for i in self.issues if i.severity == "warning"])
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Generate a summary string."""
|
||||
return (
|
||||
f"Timeline Health: {self.health_score}% | "
|
||||
f"Errors: {self.error_count} | Warnings: {self.warning_count} | "
|
||||
f"Flash frames: {len(self.flash_frames)} | Gaps: {len(self.gaps)}"
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Aparência das legendas dinâmicas: paleta, look por palavra, configuração.
|
||||
|
||||
Extraído de models.py — ver fcpxml/models/__init__.py.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from ..text_layout import REFERENCE_BLOCK_LINE_GAP, TEXT_TEMPLATE_FONT_SCALE
|
||||
|
||||
# The palette and type treatment of the calibration export
|
||||
# ("Exemplo Letra.fcpxmld", sentence "Toda a minha vida, assim,"), copied
|
||||
# verbatim from what the user set in Final Cut's Inspector.
|
||||
COLOR_INDIGO = "0.156863 0 0.596079 1"
|
||||
|
||||
COLOR_YELLOW = "0.997808 0.882664 0.0388632 1"
|
||||
|
||||
COLOR_GREY = "0.7 0.7 0.7 1"
|
||||
|
||||
COLOR_WHITE = "1 1 1 1"
|
||||
|
||||
@dataclass
|
||||
class WordLook:
|
||||
"""How one word is set: size, colour and type treatment.
|
||||
|
||||
A sentence cycles through a tuple of these, so its typography reads with a
|
||||
deliberate rhythm rather than a uniform block.
|
||||
"""
|
||||
font_size: int
|
||||
color: str
|
||||
font: str = "Helvetica Neue"
|
||||
face: Optional[str] = None # Final Cut's fontFace, e.g. "Light Italic"
|
||||
kerning: float = 2.048
|
||||
|
||||
@property
|
||||
def italic(self) -> bool:
|
||||
return bool(self.face) and "italic" in self.face.lower()
|
||||
|
||||
# One entry per word of the reference sentence, in order:
|
||||
# Toda(170, indigo, Helvetica Light) a(128, yellow) minha(151, grey)
|
||||
# vida,(128, white) assim,(128, grey, Light Italic)
|
||||
REFERENCE_RHYTHM = (
|
||||
WordLook(170, COLOR_INDIGO, font="Helvetica", face="Light", kerning=2.72),
|
||||
WordLook(128, COLOR_YELLOW),
|
||||
WordLook(151, COLOR_GREY, kerning=2.416),
|
||||
WordLook(128, COLOR_WHITE),
|
||||
WordLook(128, COLOR_GREY, face="Light Italic"),
|
||||
)
|
||||
|
||||
# The progressive-composition look (reference: the reel the user sent,
|
||||
# 2026-08-17). Supporting text in a small grotesque, the sentence's key word
|
||||
# large in a display italic, everything white — the two-font contrast IS the
|
||||
# style. Playfair Display ships in the user's ~/Library/Fonts and its real
|
||||
# advance widths are embedded in font_metrics, so the lines can be measured
|
||||
# rather than guessed. Both are plain WordLooks: swap them for any installed
|
||||
# family (a script/calligraphic face for the emphasis, say) and layout follows.
|
||||
EDITORIAL_EMPHASIS_LOOK = WordLook(
|
||||
230, COLOR_WHITE, font="Playfair Display", face="Medium Italic", kerning=0.0,
|
||||
)
|
||||
|
||||
EDITORIAL_BODY_LOOK = WordLook(
|
||||
88, COLOR_WHITE, font="Helvetica Neue", face="Bold", kerning=1.2,
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class WordStyle:
|
||||
"""Per-word text styling for dynamic (karaoke-style) subtitles.
|
||||
|
||||
``rhythm`` drives size, colour and face, cycling by the word's index within
|
||||
its sentence — deterministic, so regenerating a transcript twice yields the
|
||||
same look. ``font``/``font_size`` are the fallback when ``rhythm`` is empty.
|
||||
"""
|
||||
font: str = "Helvetica Neue"
|
||||
font_size: int = 128
|
||||
active_color: str = COLOR_WHITE
|
||||
inactive_color: str = COLOR_GREY
|
||||
bold: bool = False
|
||||
kerning: float = 2.048
|
||||
rhythm: tuple = REFERENCE_RHYTHM
|
||||
# Progressive composition only (granularity="phrase").
|
||||
emphasis_look: Optional[WordLook] = None
|
||||
body_look: Optional[WordLook] = None
|
||||
|
||||
def look_for(self, index: int) -> WordLook:
|
||||
"""The look for the word at *index* within its sentence."""
|
||||
if not self.rhythm:
|
||||
return WordLook(
|
||||
self.font_size, self.active_color,
|
||||
font=self.font, kerning=self.kerning,
|
||||
)
|
||||
return self.rhythm[index % len(self.rhythm)]
|
||||
|
||||
def look_for_emphasis(self) -> WordLook:
|
||||
"""The look for a composition's key word (progressive composition)."""
|
||||
return self.emphasis_look or EDITORIAL_EMPHASIS_LOOK
|
||||
|
||||
def look_for_body(self) -> WordLook:
|
||||
"""The look for a composition's supporting lines."""
|
||||
return self.body_look or EDITORIAL_BODY_LOOK
|
||||
|
||||
@dataclass
|
||||
class SubtitlePosition:
|
||||
"""Screen position for generated title clips, in FCP title coordinate space."""
|
||||
x: float = 0.0
|
||||
y: float = -300.0
|
||||
alignment: str = "center" # left | center | right
|
||||
|
||||
@dataclass
|
||||
class DynamicSubtitleConfig:
|
||||
"""Options for FCPXMLWriter.generate_dynamic_subtitles().
|
||||
|
||||
Dynamic subtitles are animated TITLES, not captions. Both templates below
|
||||
render on the video title lane and never carry a ``subtitles.*`` role — a
|
||||
``role="subtitles.*"`` would make Final Cut treat them as captions and
|
||||
hide them behind the caption-display toggle. They DO carry a
|
||||
``titles.*`` sub-role (``role``), which groups them in Final Cut's
|
||||
role index and lanes them with a distinct colour, without ever being
|
||||
mistaken for closed captions.
|
||||
|
||||
``animated`` picks the template: True uses "Essencial - Título"
|
||||
(Essential Title), which animates on its own Motion defaults; False uses
|
||||
the static "Título Básico" (Basic Title). Default is True — the animated
|
||||
reveal is the feature's purpose.
|
||||
|
||||
Words are grouped into sentences and laid out as a compact typographic
|
||||
block: each word becomes its own positioned ``<title>``, appearing as it is
|
||||
spoken and accumulating on screen, with every word of a block clearing at
|
||||
the same instant so the sentence vanishes as a whole.
|
||||
|
||||
``band_height`` is the fraction of frame height the block may occupy, and
|
||||
``block_center_y`` its centre in canvas points (negative is below frame
|
||||
centre). The defaults reproduce the calibration export the user built by
|
||||
hand: a block of at most three lines sitting just below centre. A sentence
|
||||
taller than the band splits into successive blocks.
|
||||
"""
|
||||
style: WordStyle = field(default_factory=WordStyle)
|
||||
position: SubtitlePosition = field(default_factory=SubtitlePosition)
|
||||
animated: bool = True
|
||||
band_height: float = 0.22
|
||||
block_center_y: float = -167.0
|
||||
# "phrase": one title per LINE of the composition — supporting words
|
||||
# grouped, the key word alone and large (the reference look). "word": one
|
||||
# title per word, the earlier rhythm.
|
||||
granularity: str = "phrase"
|
||||
# Ratio between the template's fontSize space and the canvas-point space
|
||||
# its Position uses. See text_layout.TEXT_TEMPLATE_FONT_SCALE: the "Text"
|
||||
# (Text.moti) template sizes type in frame pixels, so a size chosen in
|
||||
# points renders half as large unless it is converted on the way out.
|
||||
text_scale: float = TEXT_TEMPLATE_FONT_SCALE
|
||||
# Vertical air between stacked lines, in canvas points. Negative values
|
||||
# deliberately overlap the lines — the display italic tucking under the
|
||||
# line above is a real editorial look, and the stacking arithmetic places
|
||||
# ink boxes edge to edge, so a negative gap moves them by exactly that
|
||||
# much rather than colliding unpredictably.
|
||||
line_gap: float = REFERENCE_BLOCK_LINE_GAP
|
||||
# Final Cut role for every title this generator emits. A ``titles.*``
|
||||
# sub-role (NOT ``subtitles.*``) groups the clips in the role index and
|
||||
# tints their lane, keeping dynamic captions distinct from plain
|
||||
# ones and from Final Cut's own closed-caption toggle.
|
||||
role: str = "titles.dinamicas"
|
||||
# Run the post-generation collision validation (collision.validate_titles)
|
||||
# and refuse to emit when it reports a blocking overlap. Off by default so
|
||||
# generation stays byte-identical to before this flag existed; flip it on
|
||||
# for a guaranteed no-collision export.
|
||||
validate: bool = False
|
||||
@@ -0,0 +1,248 @@
|
||||
"""O que existe numa timeline: clipes, marcadores, lanes, projeto.
|
||||
|
||||
Extraído de models.py — ver fcpxml/models/__init__.py.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from .enums import MarkerColor, MarkerType
|
||||
from .timing import Timecode
|
||||
|
||||
|
||||
@dataclass
|
||||
class Keyword:
|
||||
"""Represents a keyword/tag applied to a clip."""
|
||||
value: str
|
||||
start: Optional[Timecode] = None
|
||||
duration: Optional[Timecode] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParametroEfeito:
|
||||
"""Um parâmetro de um filtro de efeito (``<param>`` dentro do filtro)."""
|
||||
nome: str
|
||||
valor: str
|
||||
chave: str = ""
|
||||
metadado: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class EfeitoAjuste:
|
||||
"""Um efeito aplicado por uma camada de ajuste (adjustment layer).
|
||||
|
||||
``uid`` é o UUID do efeito interno do Final Cut (ver ``FCP_EFFECTS`` em
|
||||
``fcpxml/writer/helpers.py`` para os efeitos built-in). ``tipo`` é
|
||||
``"video"`` ou ``"audio"`` — decide se vira ``<filter-video>`` ou
|
||||
``<filter-audio>``, filho direto do ``<clip>`` da camada de ajuste (o
|
||||
DTD não define wrapper ``<adjustment>``).
|
||||
"""
|
||||
nome: str
|
||||
uid: str
|
||||
tipo: str = "video"
|
||||
parametros: List[ParametroEfeito] = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class Marker:
|
||||
"""Represents a marker in the timeline."""
|
||||
name: str
|
||||
start: Timecode
|
||||
duration: Optional[Timecode] = None
|
||||
marker_type: MarkerType = MarkerType.STANDARD
|
||||
note: str = ""
|
||||
color: Optional[MarkerColor] = None
|
||||
|
||||
def to_youtube_timestamp(self) -> str:
|
||||
"""Format as YouTube chapter timestamp."""
|
||||
total_seconds = int(self.start.seconds)
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
secs = total_seconds % 60
|
||||
if hours > 0:
|
||||
return f"{hours}:{minutes:02d}:{secs:02d}"
|
||||
return f"{minutes}:{secs:02d}"
|
||||
|
||||
@dataclass
|
||||
class Clip:
|
||||
"""Represents a clip in the timeline."""
|
||||
name: str
|
||||
start: Timecode
|
||||
duration: Timecode
|
||||
source_start: Optional[Timecode] = None
|
||||
source_end: Optional[Timecode] = None
|
||||
media_path: str = ""
|
||||
markers: List[Marker] = field(default_factory=list)
|
||||
keywords: List[Keyword] = field(default_factory=list)
|
||||
|
||||
# Extended metadata
|
||||
rating: int = 0 # 0=unrated, 1-5 stars
|
||||
is_favorite: bool = False
|
||||
is_rejected: bool = False
|
||||
|
||||
# Roles (FCP audio/video role assignments)
|
||||
audio_role: str = ""
|
||||
video_role: str = ""
|
||||
|
||||
# Connected clips (B-roll, titles, audio attached to this clip)
|
||||
connected_clips: List['ConnectedClip'] = field(default_factory=list)
|
||||
|
||||
# Edit-time correction, in degrees, from a Transform filter on the clip
|
||||
# (e.g. straightening a tilted phone shot) — not the camera's own
|
||||
# recorded orientation, which lives in the media file itself.
|
||||
rotation: float = 0.0
|
||||
|
||||
@property
|
||||
def end(self) -> Timecode:
|
||||
return Timecode(
|
||||
frames=self.start.frames + self.duration.frames,
|
||||
frame_rate=self.start.frame_rate
|
||||
)
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
return self.duration.seconds
|
||||
|
||||
@property
|
||||
def keyword_values(self) -> List[str]:
|
||||
"""Get list of keyword strings."""
|
||||
return [k.value for k in self.keywords]
|
||||
|
||||
@dataclass
|
||||
class AudioClip(Clip):
|
||||
"""Audio-specific clip."""
|
||||
channels: int = 2
|
||||
sample_rate: int = 48000
|
||||
role: str = "dialogue"
|
||||
|
||||
@dataclass
|
||||
class VideoClip(Clip):
|
||||
"""Video-specific clip."""
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
has_audio: bool = True
|
||||
|
||||
@dataclass
|
||||
class ConnectedClip:
|
||||
"""A clip connected to a primary storyline clip (B-roll, titles, audio).
|
||||
|
||||
In FCP's magnetic timeline, connected clips hang off spine clips via lanes.
|
||||
Positive lanes are above (video overlays), negative lanes are below (audio).
|
||||
"""
|
||||
name: str
|
||||
start: Timecode
|
||||
duration: Timecode
|
||||
lane: int = 1
|
||||
offset: Optional[Timecode] = None
|
||||
source_start: Optional[Timecode] = None
|
||||
media_path: str = ""
|
||||
clip_type: str = "asset-clip"
|
||||
role: str = ""
|
||||
ref_id: str = ""
|
||||
parent_clip_name: str = ""
|
||||
markers: List[Marker] = field(default_factory=list)
|
||||
keywords: List[Keyword] = field(default_factory=list)
|
||||
rotation: float = 0.0
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
return self.duration.seconds
|
||||
|
||||
@dataclass
|
||||
class CompoundClip:
|
||||
"""A compound clip (ref-clip) containing a nested timeline."""
|
||||
name: str
|
||||
ref_id: str
|
||||
duration: Timecode
|
||||
start: Timecode
|
||||
clips: List[Clip] = field(default_factory=list)
|
||||
connected_clips: List[ConnectedClip] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
return self.duration.seconds
|
||||
|
||||
@dataclass
|
||||
class SilenceCandidate:
|
||||
"""A potential silence region detected by timeline heuristics."""
|
||||
start_timecode: str
|
||||
duration_seconds: float
|
||||
reason: str # "gap", "ultra_short", "name_match", "duration_anomaly"
|
||||
confidence: float = 0.5 # 0.0 to 1.0
|
||||
clip_name: Optional[str] = None
|
||||
clip_index: Optional[int] = None
|
||||
|
||||
@dataclass
|
||||
class Transition:
|
||||
"""Represents a transition between clips."""
|
||||
name: str
|
||||
duration: Timecode
|
||||
start: Timecode
|
||||
transition_type: str = "cross-dissolve"
|
||||
|
||||
@dataclass
|
||||
class Timeline:
|
||||
"""Represents a Final Cut Pro timeline/sequence."""
|
||||
name: str
|
||||
duration: Timecode
|
||||
frame_rate: float = 24.0
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
clips: List[Clip] = field(default_factory=list)
|
||||
audio_clips: List[AudioClip] = field(default_factory=list)
|
||||
transitions: List[Transition] = field(default_factory=list)
|
||||
markers: List[Marker] = field(default_factory=list)
|
||||
connected_clips: List[ConnectedClip] = field(default_factory=list)
|
||||
compound_clips: List[CompoundClip] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def total_clips(self) -> int:
|
||||
return len(self.clips)
|
||||
|
||||
@property
|
||||
def total_cuts(self) -> int:
|
||||
return max(0, len(self.clips) - 1)
|
||||
|
||||
@property
|
||||
def average_clip_duration(self) -> float:
|
||||
if not self.clips:
|
||||
return 0.0
|
||||
return sum(c.duration_seconds for c in self.clips) / len(self.clips)
|
||||
|
||||
@property
|
||||
def cuts_per_minute(self) -> float:
|
||||
"""Average cuts per minute."""
|
||||
if self.duration.seconds <= 0:
|
||||
return 0.0
|
||||
return (self.total_cuts / self.duration.seconds) * 60
|
||||
|
||||
def get_clips_shorter_than(self, seconds: float) -> List[Clip]:
|
||||
"""Find clips shorter than threshold (flash frame detection)."""
|
||||
return [c for c in self.clips if c.duration_seconds < seconds]
|
||||
|
||||
def get_clips_longer_than(self, seconds: float) -> List[Clip]:
|
||||
"""Find clips longer than threshold."""
|
||||
return [c for c in self.clips if c.duration_seconds > seconds]
|
||||
|
||||
def get_clip_at(self, timecode: float) -> Optional[Clip]:
|
||||
"""Find the clip at a specific timecode (seconds)."""
|
||||
for clip in self.clips:
|
||||
start_sec = clip.start.seconds
|
||||
end_sec = clip.end.seconds
|
||||
if start_sec <= timecode < end_sec:
|
||||
return clip
|
||||
return None
|
||||
|
||||
def get_clips_by_keyword(self, keyword: str) -> List[Clip]:
|
||||
"""Find all clips with a specific keyword."""
|
||||
return [c for c in self.clips if keyword in c.keyword_values]
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
"""Represents a Final Cut Pro project/library."""
|
||||
name: str
|
||||
timelines: List[Timeline] = field(default_factory=list)
|
||||
fcpxml_version: str = "1.13"
|
||||
|
||||
@property
|
||||
def primary_timeline(self) -> Optional[Timeline]:
|
||||
return self.timelines[0] if self.timelines else None
|
||||
@@ -0,0 +1,304 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Clip de ajuste (adjustment layer) — criação do elemento FCPXML.
|
||||
|
||||
No Final Cut, uma "camada de ajuste" é um ``<clip>`` que carrega filtros
|
||||
(``filter-video`` / ``filter-audio``) diretamente como filhos — o DTD do
|
||||
FCPXML 1.13 não define nenhum elemento ``<adjustment>`` como wrapper (ver
|
||||
``<!ELEMENT clip>`` em ``FCPXMLv1_13.dtd``: ``filter-video``/``filter-audio``
|
||||
vêm depois de ``audio-channel-source*`` e antes de ``metadata?``, sem
|
||||
elemento intermediário). Tudo que está abaixo do clip na timeline herda
|
||||
esses filtros — é como se o efeito fosse aplicado a uma faixa inteira de
|
||||
uma vez.
|
||||
|
||||
Esta classe monta esse elemento a partir de dados de alto nível (duração +
|
||||
lista de ``EfeitoAjuste``), cuidando de criar os recursos ``<effect>``
|
||||
correspondentes na seção ``<resources>`` e de referenciá-los pelos filtros.
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from ..models.timeline import EfeitoAjuste
|
||||
from ..models.timing import TimeValue
|
||||
|
||||
|
||||
def _para_racional(tempo) -> str:
|
||||
"""Aceita ``TimeValue`` ou uma string FCPXML já formatada ("90/30s")."""
|
||||
if isinstance(tempo, TimeValue):
|
||||
return tempo.to_fcpxml()
|
||||
if tempo is None:
|
||||
return "0/1s"
|
||||
return str(tempo)
|
||||
|
||||
|
||||
def _id_recurso_unico(resources: ET.Element, prefixo: str = "r_ajuste") -> str:
|
||||
"""Gera um ``id`` de recurso ainda ausente em ``resources``."""
|
||||
existentes = {r.get("id") for r in resources.findall("*") if r.get("id")}
|
||||
contador = 1
|
||||
while f"{prefixo}_{contador}" in existentes:
|
||||
contador += 1
|
||||
return f"{prefixo}_{contador}"
|
||||
|
||||
|
||||
class ClipDeAjuste:
|
||||
"""Cria um clip de ajuste (adjustment layer) pronto para a spine.
|
||||
|
||||
Exemplo::
|
||||
|
||||
from fcpxml.models.timing import TimeValue
|
||||
from fcpxml.models.timeline import EfeitoAjuste, ParametroEfeito
|
||||
from fcpxml.writer.adjustment import ClipDeAjuste
|
||||
|
||||
efeito = EfeitoAjuste(
|
||||
nome="Color Curves", uid="...UUID...", tipo="video",
|
||||
parametros=[ParametroEfeito(nome="Amount", valor="0.5",
|
||||
chave=".../9999")],
|
||||
)
|
||||
clip = ClipDeAjuste(
|
||||
nome="Ajuste de cor",
|
||||
duracao=TimeValue(300, 30),
|
||||
efeitos=[efeito],
|
||||
).criar(resources)
|
||||
spine.append(clip)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nome: str,
|
||||
duracao,
|
||||
efeitos: List[EfeitoAjuste],
|
||||
offset=None,
|
||||
formato_tc: str = "NDF",
|
||||
):
|
||||
self.nome = nome
|
||||
self.duracao = duracao
|
||||
self.efeitos = efeitos
|
||||
self.offset = offset
|
||||
self.formato_tc = formato_tc
|
||||
|
||||
def criar(
|
||||
self,
|
||||
resources: ET.Element,
|
||||
proximo_id: Optional[Callable[[], str]] = None,
|
||||
) -> ET.Element:
|
||||
"""Monta o ``<clip>`` de ajuste e seus recursos ``<effect>``.
|
||||
|
||||
``resources`` é a seção ``<resources>`` do documento (onde os
|
||||
``<effect>`` são registrados). ``proximo_id`` é um gerador opcional
|
||||
de ids de recurso; sem ele, usa um id único baseado em ``resources``.
|
||||
"""
|
||||
def gerar_id() -> str:
|
||||
if proximo_id:
|
||||
return proximo_id()
|
||||
return _id_recurso_unico(resources)
|
||||
|
||||
filtros: List[ET.Element] = []
|
||||
for efeito in self.efeitos:
|
||||
efeito_id = self._garantir_recurso(resources, efeito, gerar_id)
|
||||
filtros.append(self._montar_filtro(efeito, efeito_id))
|
||||
|
||||
clip = ET.Element(
|
||||
"clip",
|
||||
name=self.nome,
|
||||
duration=_para_racional(self.duracao),
|
||||
tcFormat=self.formato_tc,
|
||||
)
|
||||
if self.offset is not None:
|
||||
clip.set("offset", _para_racional(self.offset))
|
||||
|
||||
# O DTD exige filter-video* antes de filter-audio* como filhos
|
||||
# diretos do clip (sem wrapper <adjustment>).
|
||||
for filtro in sorted(filtros, key=lambda f: f.tag != "filter-video"):
|
||||
clip.append(filtro)
|
||||
return clip
|
||||
|
||||
def _garantir_recurso(
|
||||
self, resources: ET.Element, efeito: EfeitoAjuste, gerar_id: Callable[[], str]
|
||||
) -> str:
|
||||
"""Devolve o ``id`` do ``<effect>`` de *efeito*, criando-o se ausente."""
|
||||
for existente in resources.findall("effect"):
|
||||
if existente.get("uid") == efeito.uid:
|
||||
return existente.get("id")
|
||||
efeito_id = gerar_id()
|
||||
recurso = ET.SubElement(resources, "effect")
|
||||
recurso.set("id", efeito_id)
|
||||
recurso.set("name", efeito.nome)
|
||||
recurso.set("uid", efeito.uid)
|
||||
return efeito_id
|
||||
|
||||
def _montar_filtro(self, efeito: EfeitoAjuste, efeito_id: str) -> ET.Element:
|
||||
"""Monta o ``<filter-video>``/``<filter-audio>`` de um efeito."""
|
||||
tag = "filter-video" if efeito.tipo == "video" else "filter-audio"
|
||||
filtro = ET.Element(tag, ref=efeito_id, name=efeito.nome)
|
||||
for parametro in efeito.parametros:
|
||||
param = ET.SubElement(filtro, "param")
|
||||
param.set("name", parametro.nome)
|
||||
if parametro.chave:
|
||||
param.set("key", parametro.chave)
|
||||
param.set("value", parametro.valor)
|
||||
if parametro.metadado:
|
||||
param.set("metadata", parametro.metadado)
|
||||
return filtro
|
||||
Reference in New Issue
Block a user