Files
gart/code/fcpxml/models/subtitles.py
T
João HenriqueandClaude Sonnet 5 d13f643ebc 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>
2026-09-23 08:28:44 -04:00

166 lines
7.1 KiB
Python

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