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
+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