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,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
|
||||
Reference in New Issue
Block a user