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:
João Henrique
2026-09-23 08:28:44 -04:00
co-authored by Claude Sonnet 5
parent 0fdfe33613
commit d13f643ebc
19 changed files with 2013 additions and 29 deletions
+120
View File
@@ -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",
]
+183
View File
@@ -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"
+93
View File
@@ -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))
+121
View File
@@ -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)}"
)
+165
View File
@@ -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
+248
View File
@@ -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
+304
View File
@@ -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))
+140
View File
@@ -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