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>
249 lines
7.4 KiB
Python
249 lines
7.4 KiB
Python
"""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
|