NCAkit / modules /text_story /services /renderer.py
ismdrobiul489's picture
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"])