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>
166 lines
7.1 KiB
Python
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
|