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