chore: atualização geral
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
"""Collision detection and layout validation for dynamic-subtitle titles.
|
||||
|
||||
Pure functions — no I/O, no FCPXML parsing — that answer one question over and
|
||||
over: given the boxes a set of titles occupy on screen, do any two titles that
|
||||
are on screen at the same time intersect? And are they inside the frame, inside
|
||||
the safe area, and using a font the layout actually measured?
|
||||
|
||||
This is the post-generation guarantee the layout engine only provides *by
|
||||
construction* (``text_layout.compose_sentence`` stacks lines so their ink boxes
|
||||
never touch). Re-running it over already-emitted titles catches the cases the
|
||||
layout cannot see: a hand-edited position, a template whose type scales
|
||||
differently than ``text_scale`` assumed, a font that fell back to an estimate,
|
||||
or a word pushed off frame by a long emphasis line.
|
||||
|
||||
Boxes are measured in the *emitted* template space (frame pixels) — the same
|
||||
numbers the writer wrote to the FCPXML (``fontSize``, ``kerning`` and
|
||||
``Position`` are all already scaled by ``text_scale``), so validation re-measures
|
||||
with ``measure_text``/``ink_extent`` against those same numbers and never
|
||||
re-applies the scale factor. See ``writer.validate_subtitle_layout``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import hypot
|
||||
from typing import Dict, List, Optional, Sequence
|
||||
|
||||
from .text_layout import (
|
||||
ink_extent,
|
||||
measure_text,
|
||||
metrics_for,
|
||||
vertical_metrics_for,
|
||||
)
|
||||
|
||||
# Severity buckets for a spatial overlap, ordered from harmless to blocking.
|
||||
# ``render_tolerance`` is the 5px the renderer can round off; ``severe`` is a
|
||||
# real collision that must be fixed before export.
|
||||
OVERLAP_NONE = "none"
|
||||
OVERLAP_RENDER_TOLERANCE = "render_tolerance"
|
||||
OVERLAP_WARNING = "warning"
|
||||
OVERLAP_PROBABLE = "probable"
|
||||
OVERLAP_SEVERE = "severe"
|
||||
|
||||
# Max fraction of the smaller box a severe collision may cover (spec 7.2).
|
||||
SEVERE_OVERLAP_RATIO = 0.15
|
||||
|
||||
# issue types (spec 16)
|
||||
SPATIAL_COLLISION = "spatial_collision"
|
||||
OUTSIDE_FRAME = "outside_frame"
|
||||
OUTSIDE_SAFE_AREA = "outside_safe_area"
|
||||
INSUFFICIENT_SPACING = "insufficient_spacing"
|
||||
EXCESSIVE_SPACING = "excessive_spacing"
|
||||
FONT_MISSING = "font_missing"
|
||||
FONT_TOO_SMALL = "font_too_small"
|
||||
INVALID_ANCHOR = "invalid_anchor"
|
||||
UNRESOLVED_TRANSFORM = "unresolved_transform"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Box:
|
||||
"""An axis-aligned rectangle in frame coordinates, y growing upward."""
|
||||
|
||||
left: float
|
||||
right: float
|
||||
bottom: float
|
||||
top: float
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
return self.right - self.left
|
||||
|
||||
@property
|
||||
def height(self) -> float:
|
||||
return self.top - self.bottom
|
||||
|
||||
@property
|
||||
def area(self) -> float:
|
||||
return self.width * self.height
|
||||
|
||||
def overlaps(self, other: "Box") -> bool:
|
||||
"""True if the two boxes intersect (strict — touching edges do not)."""
|
||||
return (
|
||||
self.left < other.right
|
||||
and other.left < self.right
|
||||
and self.bottom < other.top
|
||||
and other.bottom < self.top
|
||||
)
|
||||
|
||||
|
||||
# A boundary the writer places deliberately exact — one block's title
|
||||
# duration set to literally equal the next block's start (see writer.py's
|
||||
# ``block_ends``) — can still land a few float-ULPs apart by the time it
|
||||
# gets here: an ``end`` re-derived as ``start + duration`` from two already-
|
||||
# rounded floats isn't bit-identical to a ``start`` read as one division of
|
||||
# the same exact fraction, even though both trace back to one FCPXML value.
|
||||
# Found on real footage: 7 of 8 "severe" collisions in one clip were exactly
|
||||
# this — same instant, off by ~1e-13s, nowhere near a real frame boundary
|
||||
# (~0.04s). A tolerance many orders below one frame absorbs the artifact
|
||||
# without hiding a genuine overlap.
|
||||
_BOUNDARY_EPSILON = 1e-6
|
||||
|
||||
|
||||
def temporal_overlap(
|
||||
start_a: float, end_a: float, start_b: float, end_b: float
|
||||
) -> bool:
|
||||
"""Whether the half-open intervals ``[start, end)`` intersect (spec 7.1).
|
||||
|
||||
Strict on both sides, so a title that ends exactly when the next begins is
|
||||
never treated as simultaneous — see ``_BOUNDARY_EPSILON`` for why "exactly"
|
||||
needs a tolerance rather than bare float comparison.
|
||||
"""
|
||||
return (
|
||||
start_a < end_b - _BOUNDARY_EPSILON
|
||||
and start_b < end_a - _BOUNDARY_EPSILON
|
||||
)
|
||||
|
||||
|
||||
def overlap_metrics(a: Box, b: Box) -> Dict[str, float]:
|
||||
"""Width, height, area and ratio of the intersection of ``a`` and ``b``.
|
||||
|
||||
``overlap_ratio`` is the shared area over the *smaller* box's area, so a
|
||||
small box swallowed by a big one reads as the severe case it is.
|
||||
"""
|
||||
overlap_width = min(a.right, b.right) - max(a.left, b.left)
|
||||
overlap_height = min(a.top, b.top) - max(a.bottom, b.bottom)
|
||||
overlap_area = max(0.0, overlap_width) * max(0.0, overlap_height)
|
||||
smaller = min(a.area, b.area)
|
||||
ratio = overlap_area / smaller if smaller > 0 else 0.0
|
||||
return {
|
||||
"overlap_width": overlap_width,
|
||||
"overlap_height": overlap_height,
|
||||
"overlap_area": overlap_area,
|
||||
"overlap_ratio": ratio,
|
||||
}
|
||||
|
||||
|
||||
def classify_overlap(metrics: Dict[str, float]) -> str:
|
||||
"""Severity bucket for an overlap, following spec 7.2.
|
||||
|
||||
Zero area is no conflict at all; a ratio above ``SEVERE_OVERLAP_RATIO`` is
|
||||
severe regardless of absolute size; otherwise the vertical penetration is
|
||||
bucketed into tolerance / warning / probable / severe.
|
||||
"""
|
||||
height = metrics["overlap_height"]
|
||||
if metrics["overlap_area"] <= 0:
|
||||
return OVERLAP_NONE
|
||||
if metrics["overlap_ratio"] > SEVERE_OVERLAP_RATIO:
|
||||
return OVERLAP_SEVERE
|
||||
if height <= 5:
|
||||
return OVERLAP_RENDER_TOLERANCE
|
||||
if height <= 20:
|
||||
return OVERLAP_WARNING
|
||||
if height <= 50:
|
||||
return OVERLAP_PROBABLE
|
||||
return OVERLAP_SEVERE
|
||||
|
||||
|
||||
def distance_between(a: Box, b: Box) -> Dict[str, float]:
|
||||
"""Gap between two non-overlapping boxes, per axis and euclidean (spec 8)."""
|
||||
if a.right < b.left:
|
||||
distance_x = b.left - a.right
|
||||
elif b.right < a.left:
|
||||
distance_x = a.left - b.right
|
||||
else:
|
||||
distance_x = 0.0
|
||||
|
||||
if a.top < b.bottom:
|
||||
distance_y = b.bottom - a.top
|
||||
elif b.top < a.bottom:
|
||||
distance_y = a.bottom - b.top
|
||||
else:
|
||||
distance_y = 0.0
|
||||
|
||||
return {
|
||||
"distance_x": distance_x,
|
||||
"distance_y": distance_y,
|
||||
"distance": hypot(distance_x, distance_y),
|
||||
}
|
||||
|
||||
|
||||
def separation_suggestion(
|
||||
a: Box, b: Box, min_gap: float = 0.0
|
||||
) -> Dict[str, float]:
|
||||
"""The minimum translation that separates two overlapping boxes (spec 10).
|
||||
|
||||
Picks the smallest of the four penetrations (move left/right/up/down) and
|
||||
reports that axis plus the required movement (penetration + ``min_gap``).
|
||||
"""
|
||||
move_left = a.right - b.left
|
||||
move_right = b.right - a.left
|
||||
move_down = a.top - b.bottom
|
||||
move_up = b.top - a.bottom
|
||||
candidates = [
|
||||
("horizontal", move_left),
|
||||
("horizontal", move_right),
|
||||
("vertical", move_down),
|
||||
("vertical", move_up),
|
||||
]
|
||||
axis, penetration = min(candidates, key=lambda kv: kv[1])
|
||||
return {
|
||||
"axis": axis,
|
||||
"minimum_movement": max(0.0, penetration + min_gap),
|
||||
}
|
||||
|
||||
|
||||
def measure_title_box(
|
||||
text: str,
|
||||
font_size: float,
|
||||
*,
|
||||
x: float,
|
||||
y: float,
|
||||
font: Optional[str] = None,
|
||||
face: Optional[str] = None,
|
||||
kerning: float = 0.0,
|
||||
) -> Box:
|
||||
"""The on-screen box of one title, measured in the emitted template space.
|
||||
|
||||
``font_size``/``kerning``/``x``/``y`` are the values the writer put into the
|
||||
FCPXML, so the box is comparable across every title in the document without
|
||||
any further scaling. Width comes from the real advance table, vertical
|
||||
extent from the real ink (accents and descenders included); the anchor is
|
||||
the title's centre.
|
||||
"""
|
||||
width = measure_text(
|
||||
text, font_size, kerning=kerning, font=font, face=face
|
||||
)
|
||||
top, bottom = ink_extent(text, font_size, font=font, face=face)
|
||||
return Box(
|
||||
left=x - width / 2,
|
||||
right=x + width / 2,
|
||||
bottom=y + bottom,
|
||||
top=y + top,
|
||||
)
|
||||
|
||||
|
||||
def _is_font_measured(font: Optional[str], face: Optional[str]) -> bool:
|
||||
"""Whether both advance and vertical metrics for ``font``/``face`` exist."""
|
||||
if metrics_for(font, face) is None:
|
||||
return False
|
||||
_, measured = vertical_metrics_for(font, face)
|
||||
return measured
|
||||
|
||||
|
||||
def _box_within(box: Box, limits: Box) -> bool:
|
||||
return (
|
||||
box.left >= limits.left
|
||||
and box.right <= limits.right
|
||||
and box.bottom >= limits.bottom
|
||||
and box.top <= limits.top
|
||||
)
|
||||
|
||||
|
||||
def _issue(severity: str, type_: str, **fields) -> Dict:
|
||||
return {"severity": severity, "type": type_, **fields}
|
||||
|
||||
|
||||
def validate_titles(
|
||||
titles: Sequence[Dict],
|
||||
frame_width: float,
|
||||
frame_height: float,
|
||||
*,
|
||||
safe_margin_x: float = 0.05,
|
||||
safe_margin_y: float = 0.05,
|
||||
min_font_size: Optional[float] = None,
|
||||
min_distance: Optional[float] = None,
|
||||
max_distance: Optional[float] = None,
|
||||
) -> Dict:
|
||||
"""Validate a set of already-positioned titles and return a report.
|
||||
|
||||
Each title dict must carry the emitted values:
|
||||
|
||||
- ``text`` (str)
|
||||
- ``font_size`` (float), ``kerning`` (float), ``x``/``y`` (floats)
|
||||
- ``font`` (str) and ``face`` (str|None)
|
||||
- ``start``/``end`` (seconds) for temporal overlap
|
||||
- ``group`` (hashable) for spacing checks: titles sharing a group are one
|
||||
block, expected to sit near each other (spec 14). Optional.
|
||||
|
||||
Returns ``{"severity", "issues", "summary"}`` where ``severity`` is the
|
||||
worst bucket seen and ``issues`` are the spec-16-shaped occurrences.
|
||||
"""
|
||||
frame_left = -frame_width / 2
|
||||
frame_right = frame_width / 2
|
||||
frame_bottom = -frame_height / 2
|
||||
frame_top = frame_height / 2
|
||||
frame_box = Box(frame_left, frame_right, frame_bottom, frame_top)
|
||||
|
||||
safe_box = Box(
|
||||
left=frame_left + safe_margin_x * frame_width,
|
||||
right=frame_right - safe_margin_x * frame_width,
|
||||
bottom=frame_bottom + safe_margin_y * frame_height,
|
||||
top=frame_top - safe_margin_y * frame_height,
|
||||
)
|
||||
|
||||
issues: List[Dict] = []
|
||||
boxes: List[Box] = []
|
||||
measured_flags: List[bool] = []
|
||||
for title in titles:
|
||||
text = str(title.get("text", "") or "")
|
||||
font = title.get("font") or None
|
||||
face = title.get("face") or None
|
||||
box = measure_title_box(
|
||||
text,
|
||||
float(title.get("font_size", 0.0)),
|
||||
x=float(title.get("x", 0.0)),
|
||||
y=float(title.get("y", 0.0)),
|
||||
font=font,
|
||||
face=face,
|
||||
kerning=float(title.get("kerning", 0.0)),
|
||||
)
|
||||
boxes.append(box)
|
||||
measured_flags.append(_is_font_measured(font, face))
|
||||
|
||||
if not _is_font_measured(font, face):
|
||||
issues.append(
|
||||
_issue(
|
||||
"warning",
|
||||
FONT_MISSING,
|
||||
title=text,
|
||||
font=font,
|
||||
face=face,
|
||||
message=(
|
||||
f"Font '{font or '?'}"
|
||||
+ (f" {face}" if face else "")
|
||||
+ "' has no embedded metrics; widths are estimated"
|
||||
),
|
||||
)
|
||||
)
|
||||
if min_font_size is not None and float(title.get("font_size", 0.0)) < min_font_size:
|
||||
issues.append(
|
||||
_issue(
|
||||
"warning",
|
||||
FONT_TOO_SMALL,
|
||||
title=text,
|
||||
font_size=float(title.get("font_size", 0.0)),
|
||||
minimum=min_font_size,
|
||||
)
|
||||
)
|
||||
if not _box_within(box, frame_box):
|
||||
issues.append(
|
||||
_issue(
|
||||
"error",
|
||||
OUTSIDE_FRAME,
|
||||
title=text,
|
||||
left=box.left,
|
||||
right=box.right,
|
||||
bottom=box.bottom,
|
||||
top=box.top,
|
||||
)
|
||||
)
|
||||
elif not _box_within(box, safe_box):
|
||||
issues.append(
|
||||
_issue(
|
||||
"warning",
|
||||
OUTSIDE_SAFE_AREA,
|
||||
title=text,
|
||||
left=box.left,
|
||||
right=box.right,
|
||||
bottom=box.bottom,
|
||||
top=box.top,
|
||||
)
|
||||
)
|
||||
|
||||
# Spatial collisions between temporally overlapping titles.
|
||||
for i in range(len(titles)):
|
||||
for j in range(i + 1, len(titles)):
|
||||
a, b = titles[i], titles[j]
|
||||
if not temporal_overlap(
|
||||
float(a.get("start", 0.0)), float(a.get("end", 0.0)),
|
||||
float(b.get("start", 0.0)), float(b.get("end", 0.0)),
|
||||
):
|
||||
continue
|
||||
box_a, box_b = boxes[i], boxes[j]
|
||||
if not box_a.overlaps(box_b):
|
||||
continue
|
||||
metrics = overlap_metrics(box_a, box_b)
|
||||
severity = classify_overlap(metrics)
|
||||
issues.append(
|
||||
_issue(
|
||||
severity,
|
||||
SPATIAL_COLLISION,
|
||||
first_title=str(a.get("text", "")),
|
||||
second_title=str(b.get("text", "")),
|
||||
time_start=float(a.get("start", 0.0)),
|
||||
time_end=float(b.get("end", 0.0)),
|
||||
overlap_width=metrics["overlap_width"],
|
||||
overlap_height=metrics["overlap_height"],
|
||||
overlap_area=metrics["overlap_area"],
|
||||
overlap_ratio=metrics["overlap_ratio"],
|
||||
suggested_correction=separation_suggestion(box_a, box_b),
|
||||
)
|
||||
)
|
||||
|
||||
# Spacing within a block (spec 8/14). Only when the caller asked for it —
|
||||
# a generic minimum can fire on the reference look's own tight stacking.
|
||||
if min_distance is not None or max_distance is not None:
|
||||
groups: Dict = {}
|
||||
for index, title in enumerate(titles):
|
||||
groups.setdefault(title.get("group", index), []).append(index)
|
||||
for members in groups.values():
|
||||
for m in range(len(members)):
|
||||
for n in range(m + 1, len(members)):
|
||||
i, j = members[m], members[n]
|
||||
box_a, box_b = boxes[i], boxes[j]
|
||||
if box_a.overlaps(box_b):
|
||||
continue
|
||||
gap = distance_between(box_a, box_b)["distance"]
|
||||
if min_distance is not None and gap < min_distance:
|
||||
issues.append(
|
||||
_issue(
|
||||
"warning",
|
||||
INSUFFICIENT_SPACING,
|
||||
first_title=str(titles[i].get("text", "")),
|
||||
second_title=str(titles[j].get("text", "")),
|
||||
distance=gap,
|
||||
minimum=min_distance,
|
||||
)
|
||||
)
|
||||
if max_distance is not None and gap > max_distance:
|
||||
issues.append(
|
||||
_issue(
|
||||
"warning",
|
||||
EXCESSIVE_SPACING,
|
||||
first_title=str(titles[i].get("text", "")),
|
||||
second_title=str(titles[j].get("text", "")),
|
||||
distance=gap,
|
||||
maximum=max_distance,
|
||||
)
|
||||
)
|
||||
|
||||
_rank = {
|
||||
OVERLAP_NONE: 0,
|
||||
OVERLAP_RENDER_TOLERANCE: 1,
|
||||
OVERLAP_WARNING: 2,
|
||||
OVERLAP_PROBABLE: 3,
|
||||
OVERLAP_SEVERE: 4,
|
||||
}
|
||||
severities = [issue["severity"] for issue in issues]
|
||||
worst = max(severities, key=lambda s: _rank.get(s, 0), default=OVERLAP_NONE)
|
||||
|
||||
return {
|
||||
"severity": worst,
|
||||
"issues": issues,
|
||||
"summary": {
|
||||
"title_count": len(titles),
|
||||
"issue_count": len(issues),
|
||||
"spatial_collision": sum(
|
||||
1 for i in issues if i["type"] == SPATIAL_COLLISION
|
||||
),
|
||||
"outside_frame": sum(
|
||||
1 for i in issues if i["type"] == OUTSIDE_FRAME
|
||||
),
|
||||
"outside_safe_area": sum(
|
||||
1 for i in issues if i["type"] == OUTSIDE_SAFE_AREA
|
||||
),
|
||||
"font_missing": sum(
|
||||
1 for i in issues if i["type"] == FONT_MISSING
|
||||
),
|
||||
"font_too_small": sum(
|
||||
1 for i in issues if i["type"] == FONT_TOO_SMALL
|
||||
),
|
||||
"insufficient_spacing": sum(
|
||||
1 for i in issues if i["type"] == INSUFFICIENT_SPACING
|
||||
),
|
||||
"excessive_spacing": sum(
|
||||
1 for i in issues if i["type"] == EXCESSIVE_SPACING
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def blocking(severity: str) -> bool:
|
||||
"""Whether a validation severity should block export (spec 16)."""
|
||||
return severity in (OVERLAP_SEVERE, OVERLAP_PROBABLE)
|
||||
Reference in New Issue
Block a user