chore: atualização geral

This commit is contained in:
João Henrique
2026-08-19 16:35:29 -04:00
parent 8fca456ceb
commit e7748c2c58
66 changed files with 13037 additions and 4237 deletions
+168 -7
View File
@@ -17,12 +17,27 @@ from pathlib import Path
import pytest
from fcpxml.models import DynamicSubtitleConfig, TimeValue, WordStyle
from fcpxml.models import DynamicSubtitleConfig, TimeValue, WordLook, WordStyle
from fcpxml.parser import parse_fcpxml
from fcpxml.text_layout import POINT_SCALE, REFERENCE_CANVAS_HEIGHT, ink_extent
from fcpxml.text_layout import (
POINT_SCALE,
REFERENCE_BLOCK_LINE_GAP,
REFERENCE_CANVAS_HEIGHT,
TEXT_TEMPLATE_FONT_SCALE,
LayoutBox,
compose_sentence,
ink_extent,
)
from fcpxml.writer import FCPXMLModifier
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
def font_points(style) -> float:
"""The style's size back in CANVAS POINTS.
The emitted fontSize lives in the template's own space, which is
TEXT_TEMPLATE_FONT_SCALE times bigger than the space positions use, so any
check that mixes the two has to convert first."""
return float(style.get("fontSize")) / TEXT_TEMPLATE_FONT_SCALE
# The earlier rhythm: one title per WORD. The default is now the
# progressive composition (one title per LINE), covered in
@@ -106,7 +121,7 @@ class TestGenerateDynamicSubtitles:
param_names = [p.get("name") for p in title.findall("param")]
assert param_names == [
"Position", "Layout Method", "Left Margin", "Right Margin",
"Position", "Build Out", "Layout Method", "Left Margin", "Right Margin",
"Top Margin", "Bottom Margin", "Alignment", "Line Spacing",
"Auto-Shrink", "Alignment", "Opacity", "Speed", "Custom Speed",
"Apply Speed",
@@ -132,7 +147,8 @@ class TestGenerateDynamicSubtitles:
assert style_def.get("fontFace") == first.face
scale = (1080 * POINT_SCALE) / REFERENCE_CANVAS_HEIGHT
assert style_def.get("fontSize") == str(round(first.font_size * scale))
expected = round(first.font_size * scale) * TEXT_TEMPLATE_FONT_SCALE
assert float(style_def.get("fontSize")) == expected
def test_font_size_scales_with_the_frame(self, temp_fcpxml):
"""A vertical 2160x3840 timeline must get the reference sizes back
@@ -144,7 +160,9 @@ class TestGenerateDynamicSubtitles:
title = modifier.generate_dynamic_subtitles("Interview_A", WORDS, WORD_MODE)[0]
run = title.find("text/text-style")
style_def = title.find(f"text-style-def[@id='{run.get('ref')}']/text-style")
assert style_def.get("fontSize") == str(WordStyle().rhythm[0].font_size)
assert float(style_def.get("fontSize")) == (
WordStyle().rhythm[0].font_size * TEXT_TEMPLATE_FONT_SCALE
)
def test_position_is_keyframed_constant_hold(self, temp_fcpxml):
"""Regression (2026-08-17): position is a STATIC param in the "Text"
@@ -685,7 +703,7 @@ class TestProgressiveComposition:
style = self._style(t)
top, bottom = ink_extent(
t.find("text/text-style").text,
float(style.get("fontSize")),
font_points(style),
font=style.get("font"),
face=style.get("fontFace"),
)
@@ -718,7 +736,7 @@ class TestProgressiveComposition:
style = self._style(t)
top, bottom = ink_extent(
t.find("text/text-style").text,
float(style.get("fontSize")),
font_points(style),
font=style.get("font"),
face=style.get("fontFace"),
)
@@ -762,3 +780,146 @@ class TestProgressiveComposition:
spoken = " ".join(w["word"] for w in words)
emitted = " ".join(t.find("text/text-style").text for t in titles)
assert emitted == spoken, "no spoken word may be dropped or reordered"
class TestTemplateFontScale:
"""The "Text" template sizes type in frame pixels but positions in canvas
points, so the emitted fontSize must be converted or the block renders in
the right place at half the chosen size."""
STYLE = WordStyle(
emphasis_look=WordLook(200, "1 1 1 1", font="Georgia", kerning=3.0),
body_look=WordLook(100, "1 1 1 1", font="Helvetica Neue", kerning=3.0),
)
def _styles(self, titles):
return [t.find(".//text-style-def/text-style") for t in titles]
def _generate(self, path, **kwargs):
modifier = FCPXMLModifier(path)
titles = modifier.generate_dynamic_subtitles(
"Interview_A", WORDS,
DynamicSubtitleConfig(style=self.STYLE, **kwargs),
)
return titles
def test_emitted_size_is_the_layout_size_times_the_template_scale(self, temp_fcpxml):
unscaled = self._generate(temp_fcpxml, text_scale=1.0)
scaled = self._generate(temp_fcpxml, text_scale=TEXT_TEMPLATE_FONT_SCALE)
for plain, big in zip(self._styles(unscaled), self._styles(scaled)):
assert float(big.get("fontSize")) == (
float(plain.get("fontSize")) * TEXT_TEMPLATE_FONT_SCALE
)
def test_default_config_applies_the_template_scale(self, temp_fcpxml):
assert DynamicSubtitleConfig().text_scale == TEXT_TEMPLATE_FONT_SCALE
default = self._styles(self._generate(temp_fcpxml))
unscaled = self._styles(self._generate(temp_fcpxml, text_scale=1.0))
assert [s.get("fontSize") for s in default] != [
s.get("fontSize") for s in unscaled
]
def test_kerning_scales_with_the_font_size(self, temp_fcpxml):
"""Kerning is in font units too — leaving it behind would tighten the
letter spacing to half as the type doubled."""
unscaled = self._styles(self._generate(temp_fcpxml, text_scale=1.0))
scaled = self._styles(self._generate(temp_fcpxml, text_scale=2.0))
for plain, big in zip(unscaled, scaled):
if plain.get("kerning"):
assert float(big.get("kerning")) == float(plain.get("kerning")) * 2
def _positions(self, titles):
return [
tuple(float(v) for v in t.find(
"param[@name='Position']").get("value").split())
for t in titles
]
def test_position_is_converted_with_the_type(self, temp_fcpxml):
"""The template reads fontSize and Position in the SAME space, so the
conversion has to reach both. Scaling only the type leaves the block at
the old spread with twice the type in it, and the lines collide."""
plain = self._generate(temp_fcpxml, text_scale=1.0)
big = self._generate(temp_fcpxml, text_scale=2.0)
for (x, y), (x2, y2) in zip(self._positions(plain), self._positions(big)):
assert (x2, y2) == pytest.approx((x * 2, y * 2), rel=1e-4, abs=0.01)
def test_type_and_spacing_keep_their_ratio_at_any_scale(self, temp_fcpxml):
"""The invariant that broke in the field: the distance between two
lines, measured in font sizes, must not depend on the scale."""
ratios = []
for scale in (1.0, 2.0, 3.5):
titles = self._generate(temp_fcpxml, text_scale=scale)
ys = [y for _, y in self._positions(titles)]
sizes = [float(s.get("fontSize")) for s in self._styles(titles)]
ratios.append([
(a - b) / size
for a, b, size in zip(ys, ys[1:], sizes)
])
for other in ratios[1:]:
assert other == pytest.approx(ratios[0], rel=1e-4, abs=1e-4)
class TestLineGap:
"""The air between stacked lines is a design choice, negative included."""
STYLE = WordStyle(
emphasis_look=WordLook(200, "1 1 1 1", font="Georgia", kerning=0.0),
body_look=WordLook(100, "1 1 1 1", font="Helvetica Neue", kerning=0.0),
)
PHRASE = "eu tinha muita dificuldade de encontrar roupa"
def _blocks(self, gap):
"""Compose in a band tall enough to hold every line, so the gap is the
only thing that changes — a short band would also change how many
lines fit, which is a different effect."""
words = [
{"word": w, "start": i * 0.3, "end": i * 0.3 + 0.3}
for i, w in enumerate(self.PHRASE.split())
]
box = LayoutBox(width=1080 * 0.92, height=100_000, center_y=0)
return compose_sentence(words, self.STYLE, box, line_gap=gap).blocks
def test_default_matches_the_reference_gap(self):
assert DynamicSubtitleConfig().line_gap == REFERENCE_BLOCK_LINE_GAP
def test_each_step_changes_by_exactly_the_gap(self):
"""The stack places ink boxes edge to edge, so the gap is the whole
distance between two lines beyond their own ink."""
zero = [b.y for b in self._blocks(0.0)]
loose = [b.y for b in self._blocks(50.0)]
assert len(zero) == len(loose) >= 2
for plain, spaced in zip(
[a - b for a, b in zip(zero, zero[1:])],
[a - b for a, b in zip(loose, loose[1:])],
):
assert spaced == pytest.approx(plain + 50.0)
def test_a_negative_gap_overlaps_by_exactly_that_much(self):
"""Negative is a supported look, not a failure: -40 tucks each line 40
points into the one above rather than colliding by some amount the
caller cannot predict."""
zero = [b.y for b in self._blocks(0.0)]
tucked = [b.y for b in self._blocks(-40.0)]
assert len(zero) == len(tucked) >= 2
for plain, tight in zip(
[a - b for a, b in zip(zero, zero[1:])],
[a - b for a, b in zip(tucked, tucked[1:])],
):
assert tight == pytest.approx(plain - 40.0)
def test_the_gap_reaches_the_generated_titles(self, temp_fcpxml):
"""The config field has to survive the trip to the XML."""
def spread(gap):
modifier = FCPXMLModifier(temp_fcpxml)
titles = modifier.generate_dynamic_subtitles(
"Interview_A", WORDS,
DynamicSubtitleConfig(style=self.STYLE, line_gap=gap, text_scale=1.0),
)
ys = [
float(t.find("param[@name='Position']").get("value").split()[1])
for t in titles
]
return max(ys) - min(ys)
assert spread(0.0) < spread(80.0)