| """Deterministic movie-style title cards for Small Cuts.""" |
|
|
| from PIL import Image, ImageDraw, ImageFont |
|
|
| from .styles import STYLES |
|
|
| TITLE_MAX_LEN = 60 |
|
|
| STYLE_CARDS = { |
| "deadpan": ("#F2EFE6", "#1A1A1A", "rules"), |
| "noir": ("#0D0D0F", "#E8E4D8", "hard_frame"), |
| "nature_doc": ("#0E2A1B", "#F0E9D2", "double_frame"), |
| "trailer": ("#101014", "#D4AF37", "spaced"), |
| "telenovela": ("#5C0A14", "#FFE9EC", "ornament"), |
| "symmetrist": ("#F7D6C9", "#5B3A29", "thin_frame"), |
| } |
|
|
|
|
| def derive_title(text: str, max_len: int = TITLE_MAX_LEN) -> str: |
| value = text.strip() |
| if value.startswith("["): |
| tag_end = value.find("]") |
| if tag_end != -1: |
| value = value[tag_end + 1 :].strip() |
| if not value: |
| return "Untitled Scene" |
| title = _first_clause(value).strip() |
| if not title: |
| return "Untitled Scene" |
| return _truncate_title(title, max_len) |
|
|
|
|
| def render_title_card( |
| title: str, |
| style_key: str, |
| size: tuple[int, int] = (1280, 720), |
| ) -> Image.Image: |
| style = STYLES[style_key] |
| bg, fg, treatment = STYLE_CARDS[style_key] |
| width, height = size |
| image = Image.new("RGB", size, bg) |
| draw = ImageDraw.Draw(image) |
| if treatment == "hard_frame": |
| inset = max(8, min(width, height) // 22) |
| box = (inset, inset, width - inset - 1, height - inset - 1) |
| draw.rectangle(box, outline=fg, width=2) |
| elif treatment == "double_frame": |
| inset = max(8, min(width, height) // 24) |
| for offset in (inset, inset + max(4, inset // 3)): |
| box = (offset, offset, width - offset - 1, height - offset - 1) |
| draw.rectangle(box, outline=fg) |
| elif treatment == "thin_frame": |
| draw.rectangle((8, 8, width - 9, height - 9), outline=fg) |
| margin = max(12, width // 9) |
| content_width = max(1, width - margin * 2) |
| max_title_height = max(24, int(height * 0.42)) |
| spaced = treatment == "spaced" |
| for size_px in range(max(12, int(height * 0.12)), 5, -2): |
| title_font = _font(size_px) |
| lines = _wrap(draw, title.strip().upper(), title_font, content_width, spaced) |
| block_width, block_height = _block(draw, lines, title_font) |
| if block_width <= content_width and block_height <= max_title_height: |
| break |
| else: |
| title_font = _font(6) |
| lines = _wrap(draw, title.strip().upper(), title_font, content_width, spaced) |
| _, block_height = _block(draw, lines, title_font) |
| kicker_font = _font(max(10, int(height * 0.04))) |
| subtitle_font = _font(max(10, int(height * 0.035))) |
| _center(draw, "A SMALL CUTS PICTURE", width, int(height * 0.17), kicker_font, fg) |
| if lines: |
| title_top = max(int(height * 0.28), (height - block_height) // 2) |
| title_size = getattr(title_font, "size", 12) |
| if treatment == "rules": |
| top_rule = max(8, title_top - title_size // 2) |
| bottom_rule = min(height - 8, title_top + block_height + title_size // 2) |
| draw.line((margin, top_rule, width - margin, top_rule), fill=fg) |
| draw.line((margin, bottom_rule, width - margin, bottom_rule), fill=fg) |
| line_height = _measure(draw, "Ag", title_font)[1] |
| y = title_top |
| for line in lines: |
| _center(draw, line, width, y, title_font, fg) |
| y += line_height |
| if treatment == "ornament": |
| _center(draw, "♦ ─── ♦", width, y + max(8, title_size // 4), subtitle_font, fg) |
| subtitle_y = min(height - getattr(subtitle_font, "size", 10) * 2, int(height * 0.78)) |
| _center(draw, style.label.upper(), width, subtitle_y, subtitle_font, fg) |
| return image |
|
|
|
|
| def _first_clause(text: str) -> str: |
| index = 0 |
| while index < len(text): |
| if text.startswith("...", index): |
| if _ellipsis_ends_clause(text, index, 3): |
| return text[:index] |
| index += 3 |
| continue |
| if text[index] == "…": |
| if _ellipsis_ends_clause(text, index, 1): |
| return text[:index] |
| elif text[index] in ".!?;—": |
| return text[:index] |
| index += 1 |
| return text |
|
|
|
|
| def _ellipsis_ends_clause(text: str, index: int, width: int) -> bool: |
| if len(text[:index].split()) < 3: |
| return False |
| next_index = index + width |
| if next_index >= len(text) or not text[next_index].isspace(): |
| return False |
| while next_index < len(text) and text[next_index].isspace(): |
| next_index += 1 |
| return next_index < len(text) and text[next_index].isupper() |
|
|
|
|
| def _truncate_title(title: str, max_len: int) -> str: |
| if max_len < 1: |
| return "" |
| if len(title) <= max_len: |
| return title |
| if max_len == 1: |
| return "…" |
| cut = -1 |
| for index, char in enumerate(title[:max_len]): |
| if char.isspace(): |
| cut = index |
| if cut <= 0: |
| return "…" |
| return f"{title[:cut].rstrip()}…" |
|
|
|
|
| def _font(size): |
| try: |
| return ImageFont.load_default(size=size) |
| except TypeError: |
| return ImageFont.load_default() |
|
|
|
|
| def _wrap(draw, text, font, max_width, spaced): |
| def show(value): |
| return " ".join(value) if spaced else value |
|
|
| def fits(value): |
| return _measure(draw, show(value), font)[0] <= max_width |
|
|
| lines = [] |
| current = "" |
| for word in text.split(): |
| candidate = f"{current} {word}".strip() |
| if fits(candidate): |
| current = candidate |
| continue |
| if current: |
| lines.append(show(current)) |
| current = word |
| while len(current) > 1 and not fits(current): |
| split = 1 |
| while split < len(current) and fits(current[: split + 1]): |
| split += 1 |
| lines.append(show(current[:split])) |
| current = current[split:] |
| if current: |
| lines.append(show(current)) |
| return lines |
|
|
|
|
| def _block(draw, lines, font): |
| line_height = _measure(draw, "Ag", font)[1] |
| widths = [_measure(draw, line, font)[0] for line in lines] |
| return max(widths, default=0), len(lines) * line_height |
|
|
|
|
| def _center(draw, text, width, y, font, fill): |
| x = (width - _measure(draw, text, font)[0]) / 2 |
| draw.text((x, y), text, font=font, fill=fill) |
|
|
|
|
| def _measure(draw, text, font): |
| left, top, right, bottom = draw.textbbox((0, 0), text, font=font) |
| return right - left, bottom - top |
|
|