feat: Ultrafast preset for all modules, Text Story margin fix, Quiz Explain box improvements
e93bb43 | """ | |
| Chat UI Renderer for Text Story module. | |
| Creates iMessage-style floating chat card. | |
| """ | |
| import os | |
| import logging | |
| from PIL import Image, ImageDraw, ImageFont | |
| from typing import List, Tuple, Optional | |
| import random | |
| logger = logging.getLogger(__name__) | |
| # Canvas dimensions (9:16 vertical) | |
| CANVAS_WIDTH = 1080 | |
| CANVAS_HEIGHT = 1920 | |
| # ============================================ | |
| # POSITIONING (Matched to Facebook Messenger) | |
| # ============================================ | |
| # Floating card centered on gameplay background | |
| # Reference screenshot shows: | |
| # - Floating rounded card | |
| # - Header with back arrow, avatar, name, status, icons | |
| # - White chat area with gray/blue bubbles | |
| # - Small avatar next to other person's messages | |
| LAYOUT = { | |
| "top_margin": 500, # 26% from top (moved up 8-10% from original 35%) | |
| "side_margin": 53, # +3 pixel more side margin (left/right) | |
| "chat_box_width": 974, # Slightly narrower card (1080 - 53*2) | |
| "chat_box_radius": 35, # Rounded corners | |
| "header_height": 100, # Header with avatar + name + status | |
| "max_chat_height": 1100, # Maximum height of chat box | |
| } | |
| # Colors (Facebook Messenger - exact from screenshot) | |
| COLORS = { | |
| # Header | |
| "header_bg": (255, 255, 255), # White header | |
| "header_border": (230, 230, 230), # Light gray separator line | |
| # Bubbles | |
| "bubble_other": (241, 241, 241), # Very light gray for other person | |
| "bubble_user": (0, 132, 255), # Messenger blue (#0084FF) | |
| # Text | |
| "text_white": (255, 255, 255), # White text on blue bubble | |
| "text_black": (5, 5, 5), # Black text on gray bubble | |
| "text_gray": (101, 103, 107), # Secondary text / timestamps | |
| "text_blue": (0, 132, 255), # Blue accent (back arrow, icons) | |
| "status_green": (49, 167, 75), # Active Now green dot | |
| # Background | |
| "chat_bg": (255, 255, 255), # White chat area | |
| "avatar_bg": (200, 200, 205), # Avatar placeholder gray | |
| } | |
| # UI Measurements (Messenger style) | |
| UI = { | |
| "bubble_max_width_ratio": 0.72, # 72% of chat box width | |
| "bubble_padding_h": 14, # Horizontal padding | |
| "bubble_padding_v": 10, # Vertical padding | |
| "bubble_radius": 20, # Rounded corners on bubbles | |
| "bubble_gap": 6, # Gap between messages | |
| "font_size": 30, # Main text size | |
| "header_font_size": 26, # Header name size | |
| "small_font_size": 20, # Timestamps, status | |
| "header_avatar_size": 48, # Avatar in header | |
| "msg_avatar_size": 28, # Small avatar next to messages | |
| "max_visible_messages": 8, | |
| } | |
| class ChatRenderer: | |
| """ | |
| Renders iMessage-style floating chat card. | |
| Positioned with margins matching reference image. | |
| """ | |
| def __init__(self, | |
| person_a_name: str = "You", | |
| person_b_name: str = "Unknown", | |
| person_b_avatar: str = None): | |
| self.person_a_name = person_a_name | |
| self.person_b_name = person_b_name | |
| self.person_b_avatar = person_b_avatar or person_b_name[0].upper() | |
| # Load fonts | |
| self.font = self._load_font(UI["font_size"]) | |
| self.font_header = self._load_font(UI["header_font_size"]) | |
| self.font_small = self._load_font(UI["small_font_size"]) | |
| self.font_avatar = self._load_font(24) | |
| def _load_font(self, size: int) -> ImageFont.FreeTypeFont: | |
| """Load font with fallback.""" | |
| font_paths = [ | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", | |
| "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", | |
| "C:/Windows/Fonts/arial.ttf", | |
| "/System/Library/Fonts/SFNS.ttf", | |
| ] | |
| for path in font_paths: | |
| if os.path.exists(path): | |
| try: | |
| return ImageFont.truetype(path, size) | |
| except: | |
| continue | |
| return ImageFont.load_default() | |
| def _wrap_text(self, text: str, max_width: int) -> List[str]: | |
| """Wrap text to fit within max width.""" | |
| words = text.split() | |
| lines = [] | |
| current_line = [] | |
| for word in words: | |
| test_line = " ".join(current_line + [word]) | |
| bbox = self.font.getbbox(test_line) | |
| width = bbox[2] - bbox[0] | |
| if width <= max_width: | |
| current_line.append(word) | |
| else: | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| current_line = [word] | |
| if current_line: | |
| lines.append(" ".join(current_line)) | |
| return lines if lines else [text] | |
| def _calculate_bubble_size(self, text: str) -> Tuple[int, int, List[str]]: | |
| """Calculate bubble size based on text.""" | |
| max_text_width = int(LAYOUT["chat_box_width"] * UI["bubble_max_width_ratio"]) - UI["bubble_padding_h"] * 2 | |
| lines = self._wrap_text(text, max_text_width) | |
| line_height = self.font.getbbox("Ay")[3] + 4 | |
| text_height = line_height * len(lines) | |
| max_line_width = 0 | |
| for line in lines: | |
| bbox = self.font.getbbox(line) | |
| max_line_width = max(max_line_width, bbox[2] - bbox[0]) | |
| bubble_width = max_line_width + UI["bubble_padding_h"] * 2 | |
| bubble_height = text_height + UI["bubble_padding_v"] * 2 | |
| return bubble_width, bubble_height, lines | |
| def _draw_chat_box_background(self, draw: ImageDraw.Draw, height: int): | |
| """Draw the floating chat card background with rounded corners.""" | |
| x1 = LAYOUT["side_margin"] | |
| y1 = LAYOUT["top_margin"] | |
| x2 = x1 + LAYOUT["chat_box_width"] | |
| y2 = y1 + height | |
| # Draw rounded rectangle for entire chat box | |
| draw.rounded_rectangle( | |
| [x1, y1, x2, y2], | |
| radius=LAYOUT["chat_box_radius"], | |
| fill=COLORS["chat_bg"] | |
| ) | |
| def _draw_header(self, draw: ImageDraw.Draw): | |
| """Draw Facebook Messenger style header.""" | |
| x_offset = LAYOUT["side_margin"] | |
| y_start = LAYOUT["top_margin"] | |
| # Header background | |
| header_y2 = y_start + LAYOUT["header_height"] | |
| draw.rounded_rectangle( | |
| [x_offset, y_start, x_offset + LAYOUT["chat_box_width"], header_y2], | |
| radius=LAYOUT["chat_box_radius"], | |
| fill=COLORS["header_bg"] | |
| ) | |
| # Fill bottom part (not rounded) | |
| draw.rectangle( | |
| [x_offset, y_start + 40, x_offset + LAYOUT["chat_box_width"], header_y2], | |
| fill=COLORS["header_bg"] | |
| ) | |
| # Header separator line | |
| draw.line( | |
| [(x_offset + 10, header_y2 - 1), (x_offset + LAYOUT["chat_box_width"] - 10, header_y2 - 1)], | |
| fill=COLORS.get("header_border", (230, 230, 230)), | |
| width=1 | |
| ) | |
| # Back arrow (left) - ← | |
| draw.text( | |
| (x_offset + 18, y_start + 30), | |
| "←", | |
| fill=COLORS["text_blue"], | |
| font=self.font_header | |
| ) | |
| # Avatar (left side, after back arrow) | |
| avatar_x = x_offset + 70 | |
| avatar_y = y_start + LAYOUT["header_height"] // 2 | |
| avatar_r = UI["header_avatar_size"] // 2 | |
| # Avatar circle (gray placeholder or image) | |
| draw.ellipse( | |
| [avatar_x - avatar_r, avatar_y - avatar_r, | |
| avatar_x + avatar_r, avatar_y + avatar_r], | |
| fill=COLORS.get("avatar_bg", (200, 200, 205)) | |
| ) | |
| # Avatar letter | |
| letter = self.person_b_avatar[:1].upper() | |
| bbox = self.font_avatar.getbbox(letter) | |
| text_w = bbox[2] - bbox[0] | |
| text_h = bbox[3] - bbox[1] | |
| draw.text( | |
| (avatar_x - text_w // 2, avatar_y - text_h // 2 - 2), | |
| letter, | |
| fill=COLORS["text_white"], | |
| font=self.font_avatar | |
| ) | |
| # Active Now green dot (bottom right of avatar) | |
| dot_r = 6 | |
| dot_x = avatar_x + avatar_r - 5 | |
| dot_y = avatar_y + avatar_r - 5 | |
| draw.ellipse( | |
| [dot_x - dot_r, dot_y - dot_r, dot_x + dot_r, dot_y + dot_r], | |
| fill=COLORS.get("status_green", (49, 167, 75)) | |
| ) | |
| # White border around dot | |
| draw.ellipse( | |
| [dot_x - dot_r - 2, dot_y - dot_r - 2, dot_x + dot_r + 2, dot_y + dot_r + 2], | |
| outline=COLORS["header_bg"], | |
| width=2 | |
| ) | |
| draw.ellipse( | |
| [dot_x - dot_r, dot_y - dot_r, dot_x + dot_r, dot_y + dot_r], | |
| fill=COLORS.get("status_green", (49, 167, 75)) | |
| ) | |
| # Name (beside avatar) | |
| name_x = avatar_x + avatar_r + 15 | |
| name_y = y_start + 28 | |
| draw.text( | |
| (name_x, name_y), | |
| self.person_b_name, | |
| fill=COLORS["text_black"], | |
| font=self.font_header | |
| ) | |
| # Status "Active Now" below name | |
| status_y = name_y + 30 | |
| draw.text( | |
| (name_x, status_y), | |
| "Active Now", | |
| fill=COLORS["text_gray"], | |
| font=self.font_small | |
| ) | |
| # Icons on right (phone, video, info) - using Unicode symbols | |
| right_x = x_offset + LAYOUT["chat_box_width"] - 100 | |
| icon_y = y_start + 35 | |
| # Use filled circles as icon placeholders (compatible with all fonts) | |
| draw.ellipse([right_x, icon_y + 5, right_x + 22, icon_y + 27], fill=COLORS["text_blue"]) | |
| draw.ellipse([right_x + 35, icon_y + 5, right_x + 57, icon_y + 27], fill=COLORS["text_blue"]) | |
| draw.ellipse([right_x + 70, icon_y + 5, right_x + 92, icon_y + 27], fill=COLORS["text_blue"]) | |
| def _draw_bubble(self, draw: ImageDraw.Draw, | |
| x: int, y: int, | |
| width: int, height: int, | |
| lines: List[str], | |
| is_user: bool) -> int: | |
| """Draw a chat bubble. Returns bottom Y position.""" | |
| color = COLORS["bubble_user"] if is_user else COLORS["bubble_other"] | |
| # Draw rounded rectangle | |
| draw.rounded_rectangle( | |
| [x, y, x + width, y + height], | |
| radius=UI["bubble_radius"], | |
| fill=color | |
| ) | |
| # Draw text (white on blue, black on gray) | |
| text_color = COLORS["text_white"] if is_user else COLORS.get("text_black", (5, 5, 5)) | |
| text_x = x + UI["bubble_padding_h"] | |
| text_y = y + UI["bubble_padding_v"] | |
| line_height = self.font.getbbox("Ay")[3] + 4 | |
| for line in lines: | |
| draw.text((text_x, text_y), line, fill=text_color, font=self.font) | |
| text_y += line_height | |
| return y + height | |
| def render_frame(self, messages: List[dict], show_typing: bool = False) -> Image.Image: | |
| """ | |
| Render a single frame with current messages. | |
| Args: | |
| messages: List of {"sender": "A"/"B", "text": "..."} dicts | |
| show_typing: Whether to show typing indicator | |
| Returns: | |
| PIL Image of the frame | |
| """ | |
| # Create transparent image | |
| img = Image.new("RGBA", (CANVAS_WIDTH, CANVAS_HEIGHT), (0, 0, 0, 0)) | |
| draw = ImageDraw.Draw(img) | |
| # Calculate message heights | |
| message_heights = [] | |
| visible_messages = messages[-UI["max_visible_messages"]:] | |
| for msg in visible_messages: | |
| _, height, _ = self._calculate_bubble_size(msg["text"]) | |
| message_heights.append(height + UI["bubble_gap"]) | |
| total_msg_height = sum(message_heights) | |
| # Calculate total chat box height | |
| chat_box_height = LAYOUT["header_height"] + total_msg_height + 30 # 30px bottom padding | |
| chat_box_height = min(chat_box_height, LAYOUT["max_chat_height"]) | |
| # Draw chat box background | |
| self._draw_chat_box_background(draw, chat_box_height) | |
| # Draw header | |
| self._draw_header(draw) | |
| # Draw messages | |
| x_base = LAYOUT["side_margin"] | |
| current_y = LAYOUT["top_margin"] + LAYOUT["header_height"] + 10 | |
| bubble_area_width = LAYOUT["chat_box_width"] | |
| for i, msg in enumerate(visible_messages): | |
| width, height, lines = self._calculate_bubble_size(msg["text"]) | |
| # Position: A (user) = right, B (other) = left with avatar space | |
| if msg["sender"] == "A": | |
| x = x_base + bubble_area_width - width - 15 # Right aligned with padding | |
| else: | |
| # Leave space for small avatar | |
| avatar_space = UI.get("msg_avatar_size", 28) + 8 | |
| x = x_base + avatar_space + 10 | |
| # Draw small avatar for other person (only on last consecutive B message or last message) | |
| is_last_b = (i == len(visible_messages) - 1) or (visible_messages[i+1]["sender"] != "B") | |
| if is_last_b: | |
| avatar_r = UI.get("msg_avatar_size", 28) // 2 | |
| avatar_x = x_base + 10 + avatar_r | |
| avatar_y = current_y + height - avatar_r - 2 | |
| # Avatar circle | |
| draw.ellipse( | |
| [avatar_x - avatar_r, avatar_y - avatar_r, | |
| avatar_x + avatar_r, avatar_y + avatar_r], | |
| fill=COLORS.get("avatar_bg", (200, 200, 205)) | |
| ) | |
| # Avatar letter | |
| letter = self.person_b_avatar[:1].upper() | |
| bbox = self.font_small.getbbox(letter) | |
| text_w = bbox[2] - bbox[0] | |
| text_h = bbox[3] - bbox[1] | |
| draw.text( | |
| (avatar_x - text_w // 2, avatar_y - text_h // 2 - 1), | |
| letter, | |
| fill=COLORS["text_white"], | |
| font=self.font_small | |
| ) | |
| current_y = self._draw_bubble(draw, x, current_y, width, height, lines, msg["sender"] == "A") | |
| current_y += UI["bubble_gap"] | |
| # Draw typing indicator if needed | |
| if show_typing: | |
| self._draw_typing_indicator(draw, current_y) | |
| return img | |
| def _draw_typing_indicator(self, draw: ImageDraw.Draw, y: int): | |
| """Draw typing indicator (●●●) with avatar space.""" | |
| avatar_space = UI.get("msg_avatar_size", 28) + 8 | |
| x = LAYOUT["side_margin"] + avatar_space + 10 | |
| bubble_width = 65 | |
| bubble_height = 32 | |
| draw.rounded_rectangle( | |
| [x, y, x + bubble_width, y + bubble_height], | |
| radius=16, | |
| fill=COLORS["bubble_other"] | |
| ) | |
| # Three animated dots (●●●) | |
| dot_y = y + bubble_height // 2 | |
| for dx in [16, 32, 48]: | |
| draw.ellipse( | |
| [x + dx - 4, dot_y - 4, x + dx + 4, dot_y + 4], | |
| fill=COLORS["text_gray"] | |
| ) | |
| def get_ui_height(self, messages: List[dict]) -> int: | |
| """Calculate the height of the chat UI.""" | |
| message_heights = [] | |
| visible_messages = messages[-UI["max_visible_messages"]:] | |
| for msg in visible_messages: | |
| _, height, _ = self._calculate_bubble_size(msg["text"]) | |
| message_heights.append(height + UI["bubble_gap"]) | |
| total = LAYOUT["header_height"] + sum(message_heights) + 30 | |
| return min(LAYOUT["top_margin"] + total, LAYOUT["top_margin"] + LAYOUT["max_chat_height"]) | |