chore: atualização geral
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user