chore: adiciona .gitignore e commit.command
This commit is contained in:
@@ -0,0 +1,826 @@
|
||||
"""Text measurement and block layout for kinetic-typography subtitles.
|
||||
|
||||
Lays a sentence's words out as a compact block — words packed onto lines,
|
||||
lines stacked and centered — so each word can be emitted as its own positioned
|
||||
``<title>`` without ever overlapping a sibling.
|
||||
|
||||
CALIBRATION
|
||||
-----------
|
||||
Every constant here is derived from a real Final Cut export the user built by
|
||||
hand and sent back ("Exemplo Letra.fcpxmld", project 2160x3840, template
|
||||
"Essencial - Título"). The five hand-placed words of its first sentence:
|
||||
|
||||
word position fontSize kerning
|
||||
Toda -251.828 -109 170 2.72
|
||||
a 2.236 -101.135 128 —
|
||||
minha 267.319 -95 151 2.416
|
||||
vida, 71.0395 -233.65 128 2.048
|
||||
assim, 210.88 -100 128 2.048
|
||||
|
||||
Three facts fall out of those numbers, and they are why this module can place
|
||||
words at all:
|
||||
|
||||
1. **Position uses the same unit as fontSize.** Center-to-center distances on
|
||||
line 1 are 254.06 (Toda→a) and 265.08 (a→minha). Half-width sums from the
|
||||
Helvetica metrics below at those sizes give 245.1 and 265.0 — matching to
|
||||
within a few units, which is the slop of dragging by hand. A different unit
|
||||
would have shown up as a constant ratio; there is none.
|
||||
2. **The canvas is 1080x1920 points** — half of the 2160x3840 frame, because
|
||||
Final Cut positions in points over 2x media. Line 1 spans -460.3 to +495.7,
|
||||
which fills that width with small side margins, exactly as the reference
|
||||
frame looks.
|
||||
3. **y grows upward.** "vida," (line 2) sits at -233.65 against line 1's ~-101.
|
||||
|
||||
Widths are still ESTIMATES — Final Cut renders the real glyphs — so they are
|
||||
computed generously. Overestimating costs a little empty space; underestimating
|
||||
makes two words collide, which is the one failure this module exists to
|
||||
prevent.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Sequence
|
||||
|
||||
from .font_metrics import METRICS, VERTICAL_METRICS
|
||||
|
||||
# Fallback advance widths (standard Helvetica AFM), used only for a font the
|
||||
# embedded metrics do not cover. Real per-variant metrics live in
|
||||
# font_metrics.METRICS and are preferred — see measure_text.
|
||||
_HELVETICA_WIDTHS = {
|
||||
' ': 0.278, '!': 0.278, '"': 0.355, '#': 0.556, '$': 0.556, '%': 0.889,
|
||||
'&': 0.667, "'": 0.191, '(': 0.333, ')': 0.333, '*': 0.389, '+': 0.584,
|
||||
',': 0.278, '-': 0.333, '.': 0.278, '/': 0.278, ':': 0.278, ';': 0.278,
|
||||
'<': 0.584, '=': 0.584, '>': 0.584, '?': 0.556, '@': 1.015, '[': 0.278,
|
||||
'\\': 0.278, ']': 0.278, '^': 0.469, '_': 0.556, '`': 0.333, '{': 0.334,
|
||||
'|': 0.260, '}': 0.334, '~': 0.584,
|
||||
'A': 0.667, 'B': 0.667, 'C': 0.722, 'D': 0.722, 'E': 0.667, 'F': 0.611,
|
||||
'G': 0.778, 'H': 0.722, 'I': 0.278, 'J': 0.500, 'K': 0.667, 'L': 0.556,
|
||||
'M': 0.833, 'N': 0.722, 'O': 0.778, 'P': 0.667, 'Q': 0.778, 'R': 0.722,
|
||||
'S': 0.667, 'T': 0.611, 'U': 0.722, 'V': 0.667, 'W': 0.944, 'X': 0.667,
|
||||
'Y': 0.667, 'Z': 0.611,
|
||||
'a': 0.556, 'b': 0.556, 'c': 0.500, 'd': 0.556, 'e': 0.556, 'f': 0.278,
|
||||
'g': 0.556, 'h': 0.556, 'i': 0.222, 'j': 0.222, 'k': 0.500, 'l': 0.222,
|
||||
'm': 0.833, 'n': 0.556, 'o': 0.556, 'p': 0.556, 'q': 0.556, 'r': 0.333,
|
||||
's': 0.500, 't': 0.278, 'u': 0.556, 'v': 0.500, 'w': 0.722, 'x': 0.500,
|
||||
'y': 0.500, 'z': 0.500,
|
||||
}
|
||||
_FALLBACK_WIDTH = 0.556
|
||||
|
||||
# Accented Portuguese letters advance like their base letter.
|
||||
_ACCENT_BASE = str.maketrans(
|
||||
'áàâãäéèêëíìîïóòôõöúùûüçñÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÇÑ',
|
||||
'aaaaaeeeeiiiiooooouuuucnAAAAAEEEEIIIIOOOOOUUUUCN',
|
||||
)
|
||||
|
||||
# Bold thickens every stem. Only applied on the fallback path — the embedded
|
||||
# metrics already carry the bold variant's own advances.
|
||||
_BOLD_FACTOR = 1.06
|
||||
|
||||
# Cushion over the computed advance. Small, because the embedded metrics are
|
||||
# exact: it covers the renderer's own rounding and any glyph outside the table,
|
||||
# nothing more.
|
||||
_SAFETY_MARGIN = 1.02
|
||||
|
||||
# The frame is 2160x3840 but Final Cut positions in points over 2x media, so
|
||||
# the coordinate canvas is half that. See the calibration note above.
|
||||
POINT_SCALE = 0.5
|
||||
|
||||
# The canvas the reference sizes were chosen against: 3840px tall at 2x. Other
|
||||
# formats scale off this, so a 170pt word keeps the same share of frame height
|
||||
# (8.9%) on a landscape timeline as it has on the user's vertical one.
|
||||
REFERENCE_CANVAS_HEIGHT = 3840.0 * POINT_SCALE
|
||||
|
||||
# Reference values read off the calibration export.
|
||||
REFERENCE_FONT_SIZE_LARGE = 170
|
||||
REFERENCE_FONT_SIZE_MEDIUM = 128
|
||||
REFERENCE_FONT_SIZE_ALT = 151
|
||||
REFERENCE_KERNING = 2.048
|
||||
# Center of the hand-placed block: line 1 at y≈-101, line 2 at y≈-233.65.
|
||||
REFERENCE_BLOCK_CENTER_Y = -167.0
|
||||
# Gap between words on a line, as a fraction of the larger neighbour's font
|
||||
# size. The reference export's own gaps work out to ~10-12 points at 170pt
|
||||
# (0.06), but that leaves only a few points of slack once glyph-metric error is
|
||||
# accounted for — one bad estimate and two words touch. This is deliberately
|
||||
# roomier: still a tight typographic block, with margin that survives the
|
||||
# estimate being off.
|
||||
REFERENCE_WORD_GAP_RATIO = 0.14
|
||||
|
||||
# Text occupies roughly cap-height, not the full em box, so a line's visual
|
||||
# height is well under its font size. The reference export puts line 1 (max
|
||||
# 170pt) and line 2 (128pt) 132.65 apart; with this ratio their half-heights
|
||||
# sum to 111.75, leaving the ~20pt of breathing room below. Using the full em
|
||||
# box instead would space the lines ~40% further apart than the user did.
|
||||
_CAP_HEIGHT_RATIO = 0.75
|
||||
REFERENCE_LINE_GAP = 20.0
|
||||
|
||||
# Progressive composition sets its lines much tighter than the word-by-word
|
||||
# block: in the reference the small grotesque lines almost touch the display
|
||||
# italic between them. Small but never negative — overlapping boxes is the one
|
||||
# failure this module exists to prevent.
|
||||
REFERENCE_BLOCK_LINE_GAP = 8.0
|
||||
# How far a body line slides toward its side of the emphasis line, as a
|
||||
# fraction of the slack between the two widths. 1.0 would flush it against the
|
||||
# emphasis line's edge; the reference leaves a little air.
|
||||
REFERENCE_STAGGER_RATIO = 0.8
|
||||
|
||||
|
||||
def metrics_for(font: Optional[str], face: Optional[str] = None) -> Optional[Dict]:
|
||||
"""The embedded advance table for *font*/*face*, or None if uncovered.
|
||||
|
||||
Falls back from the exact "family-face" key to the bare family, so an
|
||||
unknown face still measures against the right family rather than a
|
||||
generic table.
|
||||
"""
|
||||
if not font:
|
||||
return None
|
||||
family = font.strip().lower()
|
||||
if face:
|
||||
exact = METRICS.get(f"{family}-{face.strip().lower()}")
|
||||
if exact:
|
||||
return exact
|
||||
return METRICS.get(family)
|
||||
|
||||
|
||||
def char_width_ratio(ch: str, table: Optional[Dict] = None) -> float:
|
||||
"""Return *ch*'s advance width as a fraction of the font size."""
|
||||
if table is not None and ch in table:
|
||||
return table[ch]
|
||||
base = ch.translate(_ACCENT_BASE)
|
||||
if table is not None and base in table:
|
||||
return table[base]
|
||||
return _HELVETICA_WIDTHS.get(base, _FALLBACK_WIDTH)
|
||||
|
||||
|
||||
def measure_text(
|
||||
text: str,
|
||||
font_size: float,
|
||||
*,
|
||||
bold: bool = False,
|
||||
kerning: float = REFERENCE_KERNING,
|
||||
font: Optional[str] = None,
|
||||
face: Optional[str] = None,
|
||||
) -> float:
|
||||
"""Width of *text* rendered at *font_size*, in canvas points.
|
||||
|
||||
Measured against the real advance widths of the macOS font when *font*
|
||||
names one this module carries metrics for, which is the case for every
|
||||
font the subtitle rhythm uses. Otherwise falls back to generic Helvetica
|
||||
advances, which is an estimate.
|
||||
|
||||
``kerning`` is Final Cut's per-character tracking, in the same unit as
|
||||
font size — the calibration export carries 2.048 to 2.72.
|
||||
"""
|
||||
if not text:
|
||||
return 0.0
|
||||
table = metrics_for(font, face)
|
||||
width = sum(char_width_ratio(ch, table) for ch in text) * font_size
|
||||
width += kerning * len(text)
|
||||
if bold and table is None:
|
||||
# The embedded tables already carry the bold variant's own advances;
|
||||
# only the generic fallback needs a correction factor.
|
||||
width *= _BOLD_FACTOR
|
||||
return width * _SAFETY_MARGIN
|
||||
|
||||
|
||||
# Glyph classes for the vertical ink extent of a line. A line's real top and
|
||||
# bottom depend on WHICH characters it contains: "sua legenda" reaches the
|
||||
# x-height and dips to the descender of its g; "que vão" adds the tilde above.
|
||||
# Measuring the class actually present keeps the stack as tight as the
|
||||
# reference without ever letting two lines touch.
|
||||
_ACCENTED_UPPER = set('ÁÀÂÃÄÉÈÊËÍÌÎÏÓÒÔÕÖÚÙÛÜÑÇ')
|
||||
_ACCENTED_LOWER = set('áàâãäéèêëíìîïóòôõöúùûüñ')
|
||||
_ASCENDERS = set('bdfhklt')
|
||||
_DESCENDERS = set('gjpqyçÇ')
|
||||
_CAPS = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
|
||||
# Used when a font carries no measured vertical metrics: Helvetica's, which
|
||||
# are typical for a grotesque and tighter than a display serif's, plus a
|
||||
# cushion so an unmeasured display face still clears its neighbour.
|
||||
_FALLBACK_VERTICAL = {
|
||||
'ascent': 0.98, 'descent': -0.25, 'cap': 0.72, 'x_height': 0.53,
|
||||
'ascender': 0.73, 'accent_upper': 0.95, 'accent_lower': 0.80,
|
||||
'descender': -0.22,
|
||||
}
|
||||
_UNMEASURED_VERTICAL_CUSHION = 1.08
|
||||
|
||||
|
||||
def vertical_metrics_for(font: Optional[str], face: Optional[str] = None) -> tuple:
|
||||
"""``(metrics, measured)`` for *font*/*face* — the same lookup as metrics_for."""
|
||||
family = (font or '').strip().lower()
|
||||
if face:
|
||||
exact = VERTICAL_METRICS.get(f"{family}-{face.strip().lower()}")
|
||||
if exact:
|
||||
return exact, True
|
||||
table = VERTICAL_METRICS.get(family)
|
||||
if table:
|
||||
return table, True
|
||||
return _FALLBACK_VERTICAL, False
|
||||
|
||||
|
||||
def ink_extent(
|
||||
text: str,
|
||||
font_size: float,
|
||||
*,
|
||||
font: Optional[str] = None,
|
||||
face: Optional[str] = None,
|
||||
) -> tuple:
|
||||
"""``(top, bottom)`` of the rendered ink, in points around the title's y.
|
||||
|
||||
Final Cut centres the LINE BOX — ascent to descent — on the title's
|
||||
Position when vertical alignment is Middle, so the ink sits off-centre by
|
||||
however asymmetric the font is. Both values are returned relative to that
|
||||
centre: positive up, negative down.
|
||||
"""
|
||||
metrics, measured = vertical_metrics_for(font, face)
|
||||
baseline = -(metrics['ascent'] + metrics['descent']) / 2
|
||||
|
||||
top = metrics['x_height']
|
||||
bottom = 0.0
|
||||
for ch in text:
|
||||
if ch in _ACCENTED_UPPER:
|
||||
top = max(top, metrics['accent_upper'])
|
||||
elif ch in _ACCENTED_LOWER:
|
||||
top = max(top, metrics['accent_lower'])
|
||||
elif ch in _CAPS:
|
||||
top = max(top, metrics['cap'])
|
||||
elif ch in _ASCENDERS:
|
||||
top = max(top, metrics['ascender'])
|
||||
if ch in _DESCENDERS:
|
||||
bottom = min(bottom, metrics['descender'])
|
||||
if not measured:
|
||||
top *= _UNMEASURED_VERTICAL_CUSHION
|
||||
bottom *= _UNMEASURED_VERTICAL_CUSHION
|
||||
return (baseline + top) * font_size, (baseline + bottom) * font_size
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlacedWord:
|
||||
"""One word positioned inside a laid-out block.
|
||||
|
||||
``x``/``y`` are the word's CENTER in canvas points, origin at frame center,
|
||||
y growing upward — the convention Final Cut's position param uses, and the
|
||||
value that goes straight into the title's "Posição" param.
|
||||
"""
|
||||
text: str
|
||||
font_size: float
|
||||
italic: bool
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
line_index: int
|
||||
source: Optional[Dict] = None
|
||||
look: Optional[object] = None # the WordLook this word was set with
|
||||
kerning: float = REFERENCE_KERNING # scaled to the frame, as font_size is
|
||||
|
||||
@property
|
||||
def left(self) -> float:
|
||||
return self.x - self.width / 2
|
||||
|
||||
@property
|
||||
def right(self) -> float:
|
||||
return self.x + self.width / 2
|
||||
|
||||
@property
|
||||
def bottom(self) -> float:
|
||||
return self.y - self.height / 2
|
||||
|
||||
@property
|
||||
def top(self) -> float:
|
||||
return self.y + self.height / 2
|
||||
|
||||
def overlaps(self, other: 'PlacedWord') -> bool:
|
||||
"""True if this word's box intersects *other*'s."""
|
||||
return (
|
||||
self.left < other.right
|
||||
and other.left < self.right
|
||||
and self.bottom < other.top
|
||||
and other.bottom < self.top
|
||||
)
|
||||
|
||||
def position_param(self) -> str:
|
||||
"""The value for the title's "Posição" param, as FCP writes it."""
|
||||
return f"{self.x:g} {self.y:g}"
|
||||
|
||||
# The rest of this block is the interface a placed unit shares with
|
||||
# PlacedBlock, so the writer emits titles from either without caring
|
||||
# whether the composition is per word or per phrase.
|
||||
|
||||
@property
|
||||
def words(self) -> List[Dict]:
|
||||
return [self.source] if self.source else []
|
||||
|
||||
@property
|
||||
def start(self) -> float:
|
||||
return float(self.source.get('start', 0.0)) if self.source else 0.0
|
||||
|
||||
@property
|
||||
def end(self) -> float:
|
||||
return float(self.source.get('end', 0.0)) if self.source else 0.0
|
||||
|
||||
@property
|
||||
def font(self) -> str:
|
||||
return getattr(self.look, 'font', None) or 'Helvetica Neue'
|
||||
|
||||
@property
|
||||
def face(self) -> Optional[str]:
|
||||
return getattr(self.look, 'face', None)
|
||||
|
||||
@property
|
||||
def color(self) -> str:
|
||||
return getattr(self.look, 'color', '1 1 1 1')
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockLayout:
|
||||
"""A laid-out group of words, plus whatever did not fit."""
|
||||
placed: List[PlacedWord] = field(default_factory=list)
|
||||
overflow: List[Dict] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def fitted_count(self) -> int:
|
||||
return len(self.placed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayoutBox:
|
||||
"""The usable area words may occupy, in canvas points.
|
||||
|
||||
Defaults reproduce the calibration export: a block centered slightly below
|
||||
frame center, spanning most of the width of a 2160x3840 vertical frame.
|
||||
"""
|
||||
width: float = 1080.0 * 0.92
|
||||
height: float = 1920.0 * 0.22
|
||||
center_y: float = REFERENCE_BLOCK_CENTER_Y
|
||||
font_scale: float = 1.0
|
||||
|
||||
@classmethod
|
||||
def for_frame(
|
||||
cls,
|
||||
frame_width: float,
|
||||
frame_height: float,
|
||||
*,
|
||||
side_margin: float = 0.04,
|
||||
band_height: float = 0.22,
|
||||
center_y: Optional[float] = None,
|
||||
) -> 'LayoutBox':
|
||||
"""Build a box for a frame of *frame_width* x *frame_height* pixels.
|
||||
|
||||
Pixels are converted to canvas points via ``POINT_SCALE``. Type sizes
|
||||
and the block's height scale with the frame, so the same rhythm reads
|
||||
proportionally on any format rather than overflowing a shorter one.
|
||||
``center_y`` defaults to the calibration export's height, scaled.
|
||||
"""
|
||||
w = frame_width * POINT_SCALE
|
||||
h = frame_height * POINT_SCALE
|
||||
scale = h / REFERENCE_CANVAS_HEIGHT
|
||||
return cls(
|
||||
width=w * (1.0 - 2 * side_margin),
|
||||
height=h * band_height,
|
||||
center_y=(
|
||||
REFERENCE_BLOCK_CENTER_Y * scale if center_y is None else center_y
|
||||
),
|
||||
font_scale=scale,
|
||||
)
|
||||
|
||||
|
||||
def look_for(index: int, style):
|
||||
"""The look for the word at *index* within its sentence.
|
||||
|
||||
Prefers ``style.look_for`` (WordStyle's rhythm of WordLook entries, which
|
||||
carries size, colour, font and face together). Falls back to the older
|
||||
parallel-pattern attributes so a bare stand-in style still lays out.
|
||||
"""
|
||||
resolver = getattr(style, 'look_for', None)
|
||||
if callable(resolver):
|
||||
return resolver(index)
|
||||
|
||||
class _Fallback:
|
||||
pass
|
||||
|
||||
look = _Fallback()
|
||||
sizes = getattr(style, 'size_pattern', None)
|
||||
look.font_size = (
|
||||
float(sizes[index % len(sizes)]) if sizes
|
||||
else float(getattr(style, 'font_size', REFERENCE_FONT_SIZE_MEDIUM))
|
||||
)
|
||||
italics = getattr(style, 'italic_pattern', None)
|
||||
look.italic = bool(italics[index % len(italics)]) if italics else False
|
||||
look.font = getattr(style, 'font', 'Helvetica Neue')
|
||||
look.face = None
|
||||
look.color = getattr(style, 'active_color', '1 1 1 1')
|
||||
look.kerning = float(getattr(style, 'kerning', REFERENCE_KERNING))
|
||||
return look
|
||||
|
||||
|
||||
def rhythm_font_size(index: int, style) -> float:
|
||||
"""Font size for the word at *index* within its sentence."""
|
||||
return float(look_for(index, style).font_size)
|
||||
|
||||
|
||||
def rhythm_italic(index: int, style) -> bool:
|
||||
"""Whether the word at *index* within its sentence is italic."""
|
||||
return bool(look_for(index, style).italic)
|
||||
|
||||
|
||||
def layout_sentence(
|
||||
words: Sequence[Dict],
|
||||
style,
|
||||
box: Optional[LayoutBox] = None,
|
||||
*,
|
||||
line_gap: float = REFERENCE_LINE_GAP,
|
||||
word_gap_ratio: float = REFERENCE_WORD_GAP_RATIO,
|
||||
) -> BlockLayout:
|
||||
"""Lay *words* out as a centered, line-wrapped block inside *box*.
|
||||
|
||||
Words are packed left to right until the line no longer fits ``box.width``,
|
||||
then a new line opens. Lines are stacked, the stack centered on
|
||||
``box.center_y``, and every line centered horizontally — a compact block
|
||||
with no word ever overlapping another.
|
||||
|
||||
Words that would push the block past ``box.height`` come back in
|
||||
``BlockLayout.overflow`` instead of being placed. The caller starts a fresh
|
||||
block with them, which is what keeps a long sentence from spilling off
|
||||
screen.
|
||||
|
||||
``word_gap_ratio`` sizes the gap between neighbours off the larger of the
|
||||
two font sizes, so a 170pt word is not separated by the same sliver as a
|
||||
128pt one.
|
||||
"""
|
||||
if box is None:
|
||||
box = LayoutBox()
|
||||
|
||||
bold = bool(getattr(style, 'bold', False))
|
||||
default_kerning = float(getattr(style, 'kerning', REFERENCE_KERNING))
|
||||
# Type, spacing and gaps all scale together, or a shorter frame would get
|
||||
# reference-sized words that never fit.
|
||||
scale = float(getattr(box, 'font_scale', 1.0)) or 1.0
|
||||
line_gap *= scale
|
||||
|
||||
def gap_between(left: Dict, right: Dict) -> float:
|
||||
"""Space between two neighbouring words, off the larger of the two."""
|
||||
return max(left['font_size'], right['font_size']) * word_gap_ratio
|
||||
|
||||
tokens = []
|
||||
for w in words:
|
||||
text = str(w.get('word', '')).strip()
|
||||
if not text:
|
||||
continue
|
||||
look = look_for(len(tokens), style)
|
||||
size = float(look.font_size) * scale
|
||||
kerning = float(getattr(look, 'kerning', default_kerning)) * scale
|
||||
tokens.append({
|
||||
'source': w,
|
||||
'text': text,
|
||||
'look': look,
|
||||
'kerning': kerning,
|
||||
'font_size': size,
|
||||
'italic': bool(look.italic),
|
||||
'width': measure_text(
|
||||
text, size, bold=bold, kerning=kerning,
|
||||
font=getattr(look, 'font', None) or getattr(style, 'font', None),
|
||||
face=getattr(look, 'face', None),
|
||||
),
|
||||
'height': size * _CAP_HEIGHT_RATIO,
|
||||
})
|
||||
if not tokens:
|
||||
return BlockLayout()
|
||||
|
||||
# Pack into lines. A word wider than the whole box still gets its own line
|
||||
# rather than being dropped — losing a spoken word is worse than one line
|
||||
# running wide.
|
||||
lines: List[List[Dict]] = []
|
||||
current: List[Dict] = []
|
||||
current_width = 0.0
|
||||
for tok in tokens:
|
||||
gap = gap_between(current[-1], tok) if current else 0.0
|
||||
projected = current_width + gap + tok['width']
|
||||
if current and projected > box.width:
|
||||
lines.append(current)
|
||||
current = [tok]
|
||||
current_width = tok['width']
|
||||
else:
|
||||
current.append(tok)
|
||||
current_width = projected
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
# Keep the leading lines that fit the band; the rest overflow into a new
|
||||
# block. Consecutive lines are half-height + gap + half-height apart, so a
|
||||
# tall word only costs what it actually occupies.
|
||||
line_heights = [max(t['height'] for t in line) for line in lines]
|
||||
kept = 0
|
||||
total_height = 0.0
|
||||
for i, h in enumerate(line_heights):
|
||||
advance = h if not kept else (line_heights[i - 1] + h) / 2 + line_gap
|
||||
if kept and total_height + advance > box.height:
|
||||
break
|
||||
total_height += advance
|
||||
kept += 1
|
||||
kept = max(kept, 1) # always place one line, or the caller never advances
|
||||
|
||||
result = BlockLayout()
|
||||
for line in lines[kept:]:
|
||||
result.overflow.extend(tok['source'] for tok in line)
|
||||
|
||||
# Center the stack: the first line's center sits half the total span above
|
||||
# box.center_y, measuring the span between line CENTERS.
|
||||
span = sum(
|
||||
(line_heights[i - 1] + line_heights[i]) / 2 + line_gap
|
||||
for i in range(1, kept)
|
||||
)
|
||||
cursor_y = box.center_y + span / 2
|
||||
|
||||
for line_index, line in enumerate(lines[:kept]):
|
||||
if line_index:
|
||||
cursor_y -= (
|
||||
(line_heights[line_index - 1] + line_heights[line_index]) / 2
|
||||
+ line_gap
|
||||
)
|
||||
gaps = [gap_between(a, b) for a, b in zip(line, line[1:])]
|
||||
line_width = sum(tok['width'] for tok in line) + sum(gaps)
|
||||
cursor_x = -line_width / 2
|
||||
for position, tok in enumerate(line):
|
||||
if position:
|
||||
cursor_x += gaps[position - 1]
|
||||
result.placed.append(PlacedWord(
|
||||
text=tok['text'],
|
||||
font_size=tok['font_size'],
|
||||
italic=tok['italic'],
|
||||
x=cursor_x + tok['width'] / 2,
|
||||
y=cursor_y,
|
||||
width=tok['width'],
|
||||
height=tok['height'],
|
||||
line_index=line_index,
|
||||
source=tok['source'],
|
||||
look=tok['look'],
|
||||
kerning=tok['kerning'],
|
||||
))
|
||||
cursor_x += tok['width']
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PROGRESSIVE COMPOSITION (block-per-phrase)
|
||||
# ---------------------------------------------------------------------------
|
||||
# The look the user asked for (reference: fernandoluz.d reel, 2026-08-17):
|
||||
#
|
||||
# [ que vão ]
|
||||
# [ melhorar ]
|
||||
# [ sua legenda ]
|
||||
#
|
||||
# One title per BLOCK, not per word. Supporting words are set small in a
|
||||
# grotesque; the sentence's key word is set large in a display italic, on its
|
||||
# own line. Blocks appear as their first word is spoken and stay on screen, so
|
||||
# the sentence assembles itself; they all clear together.
|
||||
|
||||
# Function words are never the emphasis — "que", "de", "uma" set 2.5x larger
|
||||
# than the rest reads as a mistake, not as a design.
|
||||
STOPWORDS_PT = frozenset("""
|
||||
a as o os um uma uns umas de do da dos das em no na nos nas por para pra pro
|
||||
com sem sob sobre e ou mas que se ao aos à às pelo pela pelos pelas num numa
|
||||
eu tu ele ela nos vos eles elas me te lhe nos vos lhes meu minha seu sua teu
|
||||
tua nosso nossa este esta esse essa aquele aquela isso isto aquilo já não sim
|
||||
muito mais menos tão como quando onde quem qual quais é foi ser estar tem ter
|
||||
vai vou vão era são está estão dos aqui ali lá então porque assim
|
||||
""".split())
|
||||
|
||||
|
||||
def pick_emphasis_index(texts: Sequence[str]) -> int:
|
||||
"""Index of the word to set as the block's emphasis.
|
||||
|
||||
The longest content word, since length is the best proxy available for
|
||||
"the word this sentence is about" without a language model. Ties break
|
||||
toward the middle of the sentence, which is where a designer puts the
|
||||
hero word. A sentence of nothing but function words emphasises its
|
||||
longest word anyway rather than emphasising nothing.
|
||||
"""
|
||||
if not texts:
|
||||
return 0
|
||||
cleaned = [t.strip(".,!?;:…\"'()").lower() for t in texts]
|
||||
middle = (len(texts) - 1) / 2
|
||||
content = [i for i, t in enumerate(cleaned) if t and t not in STOPWORDS_PT]
|
||||
pool = content or list(range(len(texts)))
|
||||
return max(pool, key=lambda i: (len(cleaned[i]), -abs(i - middle)))
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlacedBlock:
|
||||
"""One title's worth of text, positioned as a line of the composition."""
|
||||
text: str
|
||||
words: List[Dict]
|
||||
font: str
|
||||
face: Optional[str]
|
||||
font_size: float
|
||||
color: str
|
||||
kerning: float
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
line_index: int
|
||||
emphasis: bool = False
|
||||
# Where the rendered ink actually reaches, relative to y (see ink_extent).
|
||||
ink_top: float = 0.0
|
||||
ink_bottom: float = 0.0
|
||||
|
||||
@property
|
||||
def left(self) -> float:
|
||||
return self.x - self.width / 2
|
||||
|
||||
@property
|
||||
def right(self) -> float:
|
||||
return self.x + self.width / 2
|
||||
|
||||
@property
|
||||
def bottom(self) -> float:
|
||||
return self.y + self.ink_bottom
|
||||
|
||||
@property
|
||||
def top(self) -> float:
|
||||
return self.y + self.ink_top
|
||||
|
||||
@property
|
||||
def start(self) -> float:
|
||||
"""When this block is spoken — its first word's start, in seconds."""
|
||||
return min(float(w.get('start', 0.0)) for w in self.words)
|
||||
|
||||
@property
|
||||
def end(self) -> float:
|
||||
"""When this block finishes being spoken, in seconds."""
|
||||
return max(float(w.get('end', 0.0)) for w in self.words)
|
||||
|
||||
def position_param(self) -> str:
|
||||
"""The value for the title's "Posição" param, as FCP writes it."""
|
||||
return f"{self.x:g} {self.y:g}"
|
||||
|
||||
def overlaps(self, other: 'PlacedBlock') -> bool:
|
||||
return (
|
||||
self.left < other.right
|
||||
and other.left < self.right
|
||||
and self.bottom < other.top
|
||||
and other.bottom < self.top
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Composition:
|
||||
"""A laid-out group of blocks, plus whatever did not fit."""
|
||||
blocks: List[PlacedBlock] = field(default_factory=list)
|
||||
overflow: List[Dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def compose_sentence(
|
||||
words: Sequence[Dict],
|
||||
style,
|
||||
box: Optional[LayoutBox] = None,
|
||||
*,
|
||||
line_gap: float = REFERENCE_BLOCK_LINE_GAP,
|
||||
stagger_ratio: float = REFERENCE_STAGGER_RATIO,
|
||||
) -> Composition:
|
||||
"""Lay a sentence out as stacked blocks, one title per line.
|
||||
|
||||
The emphasis word takes a line of its own, set in the display face; the
|
||||
words before and after it fill the lines above and below, wrapped at
|
||||
``box.width`` and set in the body face. Body lines are staggered — pushed
|
||||
toward opposite edges of the emphasis line — which is what makes the
|
||||
composition read as diagrammed rather than as a centred caption.
|
||||
|
||||
Lines that would push the stack past ``box.height`` come back in
|
||||
``Composition.overflow`` for the caller to place as the next composition.
|
||||
"""
|
||||
if box is None:
|
||||
box = LayoutBox()
|
||||
scale = float(getattr(box, 'font_scale', 1.0)) or 1.0
|
||||
|
||||
entries = [
|
||||
(w, str(w.get('word', '')).strip())
|
||||
for w in words
|
||||
if str(w.get('word', '')).strip()
|
||||
]
|
||||
if not entries:
|
||||
return Composition()
|
||||
|
||||
texts = [t for _, t in entries]
|
||||
emphasis_index = pick_emphasis_index(texts)
|
||||
emphasis_look = style.look_for_emphasis()
|
||||
body_look = style.look_for_body()
|
||||
|
||||
def measure(text: str, look) -> tuple:
|
||||
size = float(look.font_size) * scale
|
||||
kerning = float(getattr(look, 'kerning', REFERENCE_KERNING)) * scale
|
||||
width = measure_text(
|
||||
text, size,
|
||||
bold=bool(getattr(style, 'bold', False)),
|
||||
kerning=kerning,
|
||||
font=getattr(look, 'font', None),
|
||||
face=getattr(look, 'face', None),
|
||||
)
|
||||
return size, kerning, width
|
||||
|
||||
# Split into lines: everything before the emphasis, the emphasis alone,
|
||||
# everything after. Body runs wrap at the box width so a long lead-in
|
||||
# becomes two lines instead of running off frame.
|
||||
def body_lines(run: List[tuple]) -> List[List[tuple]]:
|
||||
out: List[List[tuple]] = []
|
||||
current: List[tuple] = []
|
||||
for item in run:
|
||||
trial = current + [item]
|
||||
text = ' '.join(t for _, t in trial)
|
||||
if current and measure(text, body_look)[2] > box.width:
|
||||
out.append(current)
|
||||
current = [item]
|
||||
else:
|
||||
current = trial
|
||||
if current:
|
||||
out.append(current)
|
||||
return out
|
||||
|
||||
lines: List[tuple] = [] # (run, look, is_emphasis)
|
||||
for run in body_lines(entries[:emphasis_index]):
|
||||
lines.append((run, body_look, False))
|
||||
lines.append(([entries[emphasis_index]], emphasis_look, True))
|
||||
for run in body_lines(entries[emphasis_index + 1:]):
|
||||
lines.append((run, body_look, False))
|
||||
|
||||
measured = []
|
||||
for run, look, is_emphasis in lines:
|
||||
text = ' '.join(t for _, t in run)
|
||||
size, kerning, width = measure(text, look)
|
||||
# Stack on the real ink each line contains, not on a nominal
|
||||
# cap-height: the display italic's accents and descenders run well
|
||||
# past it, and a nominal box lets them collide with the neighbour.
|
||||
top, bottom = ink_extent(
|
||||
text, size,
|
||||
font=getattr(look, 'font', None), face=getattr(look, 'face', None),
|
||||
)
|
||||
measured.append({
|
||||
'run': run, 'look': look, 'emphasis': is_emphasis, 'text': text,
|
||||
'font_size': size, 'kerning': kerning, 'width': width,
|
||||
'ink_top': top, 'ink_bottom': bottom, 'height': top - bottom,
|
||||
})
|
||||
|
||||
# Keep the leading lines that fit the band; the rest become the next
|
||||
# composition. The emphasis line must survive — a block of only body text
|
||||
# loses the whole point of the look — so if it does not fit, everything
|
||||
# from the emphasis on overflows together.
|
||||
gap = line_gap * scale
|
||||
kept = 0
|
||||
total = 0.0
|
||||
for line in measured:
|
||||
advance = line['height'] if not kept else line['height'] + gap
|
||||
if kept and total + advance > box.height:
|
||||
break
|
||||
total += advance
|
||||
kept += 1
|
||||
kept = max(kept, 1)
|
||||
if not any(line['emphasis'] for line in measured[:kept]):
|
||||
kept = min(kept, next(
|
||||
i for i, line in enumerate(measured) if line['emphasis']
|
||||
)) or 1
|
||||
|
||||
result = Composition()
|
||||
for line in measured[kept:]:
|
||||
result.overflow.extend(w for w, _ in line['run'])
|
||||
|
||||
visible = measured[:kept]
|
||||
# Stack the ink boxes edge to edge with exactly *gap* between them, then
|
||||
# centre the whole stack on the band. Because the boxes are the real ink,
|
||||
# "no overlap" is a property of the arithmetic, not of a safety factor.
|
||||
stack_height = (
|
||||
sum(line['height'] for line in visible) + gap * (len(visible) - 1)
|
||||
)
|
||||
edge = box.center_y + stack_height / 2
|
||||
|
||||
# Body lines hang off the emphasis line's edges, alternating sides in
|
||||
# reading order — the first body line to the left, the next to the right.
|
||||
anchor = max(line['width'] for line in visible)
|
||||
side = -1
|
||||
for index, line in enumerate(visible):
|
||||
if index:
|
||||
edge -= gap
|
||||
cursor_y = edge - line['ink_top']
|
||||
edge = cursor_y + line['ink_bottom']
|
||||
if line['emphasis']:
|
||||
x = 0.0
|
||||
else:
|
||||
x = side * (anchor - line['width']) / 2 * stagger_ratio
|
||||
side = -side
|
||||
look = line['look']
|
||||
result.blocks.append(PlacedBlock(
|
||||
text=line['text'],
|
||||
words=[w for w, _ in line['run']],
|
||||
font=getattr(look, 'font', 'Helvetica Neue'),
|
||||
face=getattr(look, 'face', None),
|
||||
font_size=line['font_size'],
|
||||
color=getattr(look, 'color', '1 1 1 1'),
|
||||
kerning=line['kerning'],
|
||||
x=x,
|
||||
y=cursor_y,
|
||||
width=line['width'],
|
||||
height=line['height'],
|
||||
ink_top=line['ink_top'],
|
||||
ink_bottom=line['ink_bottom'],
|
||||
line_index=index,
|
||||
emphasis=line['emphasis'],
|
||||
))
|
||||
return result
|
||||
Reference in New Issue
Block a user