473 lines
17 KiB
Python
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)
|