Files
gart/code/fcpxml/collision.py

473 lines
17 KiB
Python

"""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)