small-cuts / src /small_cuts /title_card.py
macayaven's picture
Upload folder using huggingface_hub
24e5b39 verified
Raw
History Blame Contribute Delete
6.37 kB
"""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