diff --git a/plugins.json b/plugins.json index 11a4eaf..2c5aab6 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-04-09", + "last_updated": "2026-04-10", "plugins": [ { "id": "hello-world", @@ -702,7 +702,7 @@ "last_updated": "2026-04-09", "verified": true, "screenshot": "", - "latest_version": "2.2.7" + "latest_version": "2.3.0" }, { "id": "web-ui-info", diff --git a/plugins/masters-tournament/manager.py b/plugins/masters-tournament/manager.py index 4fff105..fccd36f 100644 --- a/plugins/masters-tournament/manager.py +++ b/plugins/masters-tournament/manager.py @@ -124,7 +124,7 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man self._last_hole_advance = {} # per-mode hole timers self._hole_switch_interval = config.get("hole_display_duration", 15) self._last_fact_advance = 0 - self._fact_advance_interval = 2 # seconds between scroll steps + self._fact_advance_interval = 3 # seconds between scroll steps self._last_page_advance = {} # per-mode page timers self._page_interval = config.get("page_display_duration", 15) @@ -508,8 +508,12 @@ def _display_fun_facts(self, force_clear: bool) -> bool: if now - self._last_fact_advance >= self._fact_advance_interval: self._fact_scroll += 1 self._last_fact_advance = now - # Move to next fact after scrolling through - if self._fact_scroll > 5: + # Derive scroll steps from actual wrapped line count for this fact. + total_lines, visible = self.renderer.get_fun_fact_line_count( + self._fact_index, + ) + max_scroll = max(1, total_lines - visible + 1) + if self._fact_scroll >= max_scroll: self._fact_index += 1 self._fact_scroll = 0 return result @@ -570,13 +574,18 @@ def get_vegas_content(self) -> Optional[List[Image.Image]]: if card: cards.append(card) - # Fun facts - for i in range(5): - card = self.renderer.render_fun_fact( - i, card_width=cw, card_height=ch, - ) - if card: - cards.append(card) + # Fun facts — respect user's enabled setting. + # Use single-line wide cards so horizontal scroll reveals the full text. + fun_facts_enabled = self.config.get("display_modes", {}).get( + "fun_facts", {} + ).get("enabled", True) + if fun_facts_enabled: + for i in range(5): + card = self.renderer.render_fun_fact_vegas( + i, card_height=ch, + ) + if card: + cards.append(card) return cards if cards else None diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index ba05e19..2b4b6c1 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.2.7", + "version": "2.3.0", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -44,8 +44,8 @@ }, "versions": [ { - "version": "2.2.7", - "released": "2026-04-09", + "version": "2.3.0", + "released": "2026-04-10", "ledmatrix_min_version": "2.0.0" }, { diff --git a/plugins/masters-tournament/masters_renderer.py b/plugins/masters-tournament/masters_renderer.py index a79e254..1ea1992 100644 --- a/plugins/masters-tournament/masters_renderer.py +++ b/plugins/masters-tournament/masters_renderer.py @@ -81,6 +81,7 @@ "assets/fonts", "../../../assets/fonts", "../../assets/fonts", + str(Path(__file__).parent.parent.parent / "assets" / "fonts"), str(Path.home() / "Github" / "LEDMatrix" / "assets" / "fonts"), ] @@ -152,6 +153,25 @@ def _load_font_sized(filename: str, size: int) -> Optional[ImageFont.ImageFont]: _BDF_TEMP_DIR: Optional[str] = None +def _cleanup_bdf_temp() -> None: + """Remove the BDF temp directory on process exit.""" + global _BDF_TEMP_DIR + if _BDF_TEMP_DIR is None: + return + try: + import shutil + shutil.rmtree(_BDF_TEMP_DIR) + except Exception as e: + logger.debug("Failed to remove BDF temp dir %s: %s", _BDF_TEMP_DIR, e) + finally: + _BDF_TEMP_DIR = None + _BDF_FONT_CACHE.clear() + + +import atexit +atexit.register(_cleanup_bdf_temp) + + def _load_bdf_font(filename: str) -> Optional[ImageFont.ImageFont]: """Load a BDF bitmap font and return it as an ImageFont. @@ -280,6 +300,19 @@ def _configure_tier(self): flag_h = max(8, min(self.row_height + 1, 10)) self.flag_size = (int(flag_h * 1.4), flag_h) + # Wide-short panels with very limited height (128x32, 256x32) need + # compact vertical sizing — the large-tier defaults consume too much + # of the 32px budget (header 11 + footer 6 + row 9 = 26 of 32). + if self.is_wide_short and self.height <= 32: + self.row_height = 7 + self.header_height = 8 + self.footer_height = 5 + self.show_headshot = False + self.headshot_size = 0 + self.show_country = False + flag_h = max(6, min(self.row_height, 7)) + self.flag_size = (int(flag_h * 1.4), flag_h) + # Compute max_players from actual available vertical space. available_h = self.height - self.header_height - self.footer_height - 2 slot_h = self.row_height + self.row_gap @@ -302,6 +335,11 @@ def _load_fonts(self): self.font_score = _load_font("medium") self.font_detail = _load_font("small") + # Wide-short 32px: PressStart2P at 8px is too tall for 8px header + if self.is_wide_short and self.height <= 32: + self.font_header = _load_font("small") + self.font_score = _load_font("small") + # ═══════════════════════════════════════════════════════════ # DRAWING HELPERS # ═══════════════════════════════════════════════════════════ @@ -1053,6 +1091,57 @@ def render_past_champions(self, page: int = 0) -> Optional[Image.Image]: # FUN FACTS - Scrolling text # ═══════════════════════════════════════════════════════════ + def _wrap_text(self, text: str, max_w: int, + font, draw) -> List[str]: + """Word-wrap *text* to fit within *max_w* pixels using *font*. + + Words wider than *max_w* are broken at character boundaries. + """ + words = text.split() + lines: List[str] = [] + current_line = "" + for word in words: + # Break oversized words into chunks that fit. + if self._text_width(draw, word, font) > max_w: + for ch in word: + test = current_line + ch + if self._text_width(draw, test, font) <= max_w: + current_line = test + else: + if current_line: + lines.append(current_line) + current_line = ch + continue + test = f"{current_line} {word}".strip() + if self._text_width(draw, test, font) <= max_w: + current_line = test + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + return lines + + def get_fun_fact_line_count(self, fact_index: int, + card_width: Optional[int] = None, + card_height: Optional[int] = None) -> tuple: + """Return (total_lines, visible_lines) for a fun fact at this display size.""" + cw = card_width if card_width is not None else self.width + ch = card_height if card_height is not None else self.height + + fact = get_fun_fact_by_index(fact_index) + tmp = Image.new("RGB", (1, 1)) + draw = ImageDraw.Draw(tmp) + + font = self.font_detail + line_h = self._text_height(draw, "Ag", font) + 2 + content_top = self.header_height + 4 + + lines = self._wrap_text(fact, cw - 10, font, draw) + visible = max(1, (ch - content_top - 4) // line_h) + return len(lines), visible + def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, card_width: Optional[int] = None, card_height: Optional[int] = None) -> Optional[Image.Image]: @@ -1083,23 +1172,12 @@ def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, line_h = self._text_height(draw, "Ag", font) + 2 # Extra line spacing max_w = cw - 10 # More horizontal padding - words = fact.split() - lines = [] - current_line = "" - for word in words: - test = f"{current_line} {word}".strip() - if self._text_width(draw, test, font) <= max_w: - current_line = test - else: - if current_line: - lines.append(current_line) - current_line = word - if current_line: - lines.append(current_line) + lines = self._wrap_text(fact, max_w, font, draw) + total_lines = len(lines) # Apply scroll offset (for long facts) visible_lines = max(1, (ch - content_top - 4) // line_h) - if len(lines) > visible_lines: + if total_lines > visible_lines: start_line = scroll_offset % max(1, len(lines) - visible_lines + 1) lines = lines[start_line : start_line + visible_lines] @@ -1110,7 +1188,7 @@ def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, y += line_h # Scroll indicator if text is long - if len(words) > visible_lines * 4: # Rough heuristic + if total_lines > visible_lines: # Small down arrow ax = cw - 6 ay = ch - 6 @@ -1119,6 +1197,49 @@ def render_fun_fact(self, fact_index: int = -1, scroll_offset: int = 0, return img + def render_fun_fact_vegas(self, fact_index: int = -1, + card_height: int = 32) -> Optional[Image.Image]: + """Render a fun fact as a single-line wide card for vegas horizontal scroll. + + The card is exactly as wide as needed to fit the full text on one + line below the header, so the vegas scroller reveals it naturally. + """ + ch = card_height + + if fact_index < 0: + fact = get_random_fun_fact() + else: + fact = get_fun_fact_by_index(fact_index) + + font = self.font_detail + # Measure the full single-line text width + tmp = Image.new("RGB", (1, 1)) + tmp_draw = ImageDraw.Draw(tmp) + title = "DID YOU KNOW?" + title_w = self._text_width(tmp_draw, title, self.font_header) + 8 + text_w = self._text_width(tmp_draw, fact, font) + text_h = self._text_height(tmp_draw, "Ag", font) + + # Card wide enough for header title or text, whichever is longer + cw = max(title_w, text_w + 10) + + img = self._draw_gradient_bg(COLORS["bg"], COLORS["bg_dark_green"], + width=cw, height=ch) + draw = ImageDraw.Draw(img) + + # Header + header_h = self.header_height + draw.rectangle([(0, 0), (cw - 1, header_h - 1)], fill=COLORS["masters_green"]) + draw.line([(0, header_h - 1), (cw, header_h - 1)], fill=COLORS["masters_yellow"]) + self._text_shadow(draw, (3, 1), title, self.font_header, COLORS["masters_yellow"]) + + # Single line of text, vertically centered in remaining space + content_top = header_h + 1 + text_y = content_top + max(0, (ch - content_top - text_h) // 2) + draw.text((5, text_y), fact, fill=COLORS["white"], font=font) + + return img + # ═══════════════════════════════════════════════════════════ # TOURNAMENT STATS - Paginated (2 pages) # ═══════════════════════════════════════════════════════════ @@ -1180,51 +1301,111 @@ def render_schedule(self, schedule_data: List[Dict], page: int = 0) -> Optional[ content_top = self.header_height + 2 content_bottom = self.height - self.footer_height - 2 - entry_h = (self.row_height + self.row_gap) * 2 + 2 - rows = max(1, (content_bottom - content_top) // entry_h) + avail_h = content_bottom - content_top two_column = self.is_wide_short cols = 2 if two_column else 1 - per_page = rows * cols - - total_pages = max(1, (len(schedule_data) + per_page - 1) // per_page) - page = page % total_pages - start = page * per_page - entries = schedule_data[start : start + per_page] - col_w = self.width // cols - if two_column: - draw.line([(col_w, content_top), (col_w, content_bottom)], - fill=COLORS["masters_dark"]) # Masters pairings are almost always threesomes; always build text # from the full list and let the width-clipping loop shorten it. - # Dropping the third golfer up front would hide half of who's in the - # group on wide-short layouts. name_budget = 10 if not two_column else 9 - for i, entry in enumerate(entries): - col = i // rows - row = i % rows - cx = col * col_w + 3 - cx_right = (col + 1) * col_w - 3 - y = content_top + row * entry_h - - # Time in yellow - time_text = entry.get("time", "") - draw.text((cx, y), time_text, - fill=COLORS["masters_yellow"], font=self.font_body) - y += self.row_height + 1 + if self.height >= 48: + # ── Standard layout: time on one line, each player stacked below ── + detail_h = self._text_height(draw, "Ag", self.font_detail) + 1 + # Time row + up to 3 player rows + gap + player_rows = min(3, max(1, (avail_h - self.row_height - 1) // detail_h)) + entry_h = self.row_height + 1 + player_rows * detail_h + 2 + rows = max(1, avail_h // entry_h) + per_page = rows * cols + + total_pages = max(1, (len(schedule_data) + per_page - 1) // per_page) + page = page % total_pages + entries = schedule_data[page * per_page : (page + 1) * per_page] + + if two_column: + draw.line([(col_w, content_top), (col_w, content_bottom)], + fill=COLORS["masters_dark"]) + + for i, entry in enumerate(entries): + col = i // rows + row = i % rows + cx = col * col_w + 3 + cx_right = (col + 1) * col_w - 3 + y = content_top + row * entry_h + + time_text = entry.get("time", "") + draw.text((cx, y), time_text, + fill=COLORS["masters_yellow"], font=self.font_body) + y += self.row_height + 1 + + players = entry.get("players", []) or [] + max_name_w = cx_right - cx - 3 + if len(players) <= player_rows: + # All players fit on their own line + for p in players: + name = format_player_name(p, name_budget) + while name and self._text_width(draw, name, self.font_detail) > max_name_w: + name = name[:-1] + draw.text((cx + 3, y), name, + fill=COLORS["white"], font=self.font_detail) + y += detail_h + else: + # More players than rows — show first (player_rows-1) + # individually, fold the rest into the last line. + solo = max(0, player_rows - 1) + for p in players[:solo]: + name = format_player_name(p, name_budget) + while name and self._text_width(draw, name, self.font_detail) > max_name_w: + name = name[:-1] + draw.text((cx + 3, y), name, + fill=COLORS["white"], font=self.font_detail) + y += detail_h + # Last line: remaining players comma-separated + overflow = ", ".join( + format_player_name(p, name_budget) for p in players[solo:] + ) + while overflow and self._text_width(draw, overflow, self.font_detail) > max_name_w: + overflow = overflow[:-1] + draw.text((cx + 3, y), overflow, + fill=COLORS["white"], font=self.font_detail) + y += detail_h + else: + # ── Compact layout (height < 48): tighter spacing ── + # Time + comma-separated players on adjacent lines with minimal gap + entry_h = self.row_height + self.row_gap + self.row_height + rows = max(1, avail_h // entry_h) + per_page = rows * cols + + total_pages = max(1, (len(schedule_data) + per_page - 1) // per_page) + page = page % total_pages + entries = schedule_data[page * per_page : (page + 1) * per_page] + + if two_column: + draw.line([(col_w, content_top), (col_w, content_bottom)], + fill=COLORS["masters_dark"]) + + for i, entry in enumerate(entries): + col = i // rows + row = i % rows + cx = col * col_w + 3 + cx_right = (col + 1) * col_w - 3 + y = content_top + row * entry_h + + time_text = entry.get("time", "") + draw.text((cx, y), time_text, + fill=COLORS["masters_yellow"], font=self.font_detail) + y += self.row_height + self.row_gap - # Players — build from full list, clip to column width - players = entry.get("players", []) or [] - players_text = ", ".join( - format_player_name(p, name_budget) for p in players - ) - while players_text and self._text_width(draw, players_text, self.font_detail) > (cx_right - cx - 3): - players_text = players_text[:-1] - draw.text((cx + 3, y), players_text, - fill=COLORS["white"], font=self.font_detail) + players = entry.get("players", []) or [] + players_text = ", ".join( + format_player_name(p, name_budget) for p in players + ) + while players_text and self._text_width(draw, players_text, self.font_detail) > (cx_right - cx - 3): + players_text = players_text[:-1] + draw.text((cx + 3, y), players_text, + fill=COLORS["white"], font=self.font_detail) self._draw_page_dots(draw, page, total_pages) return img diff --git a/plugins/masters-tournament/masters_renderer_enhanced.py b/plugins/masters-tournament/masters_renderer_enhanced.py index 553196d..92b4243 100644 --- a/plugins/masters-tournament/masters_renderer_enhanced.py +++ b/plugins/masters-tournament/masters_renderer_enhanced.py @@ -261,6 +261,7 @@ def render_hole_card(self, hole_number: int, return self._render_hole_card_with_image( img, draw, hole_number, hole_info, cw, ch, ) + return self._render_hole_card_compact( img, draw, hole_number, hole_info, cw, ch, ) @@ -275,11 +276,18 @@ def _render_hole_card_with_image(self, img, draw, hole_number: int, """ # Left panel width: wide enough to fit "Golden Bell" and par/yardage text. # Grows with card width so 256x64 cards get more text room than 128x48. + # If the card is too narrow for a useful image, let text fill the width. left_w = max(38, min(56, cw // 3)) + img_w = cw - left_w - 4 + if img_w < 20: + left_w = cw + + show_image = left_w < cw # Left panel background strip draw.rectangle([(0, 0), (left_w - 1, ch - 1)], fill=COLORS["masters_dark"]) - draw.line([(left_w - 1, 0), (left_w - 1, ch)], fill=COLORS["masters_yellow"]) + if show_image: + draw.line([(left_w - 1, 0), (left_w - 1, ch)], fill=COLORS["masters_yellow"]) line_h = self._text_height(draw, "A", self.font_detail) + 1 max_text_w = left_w - 4 @@ -347,31 +355,32 @@ def _render_hole_card_with_image(self, img, draw, hole_number: int, draw.text(((left_w - lw) // 2, name_y + i * line_h), line, fill=COLORS["masters_yellow"], font=self.font_detail) - # Right side: hole layout image - img_x = left_w + 2 - img_w = cw - img_x - 2 - img_h = ch - 4 - hole_img = self.logo_loader.get_hole_image( - hole_number, - max_width=img_w, - max_height=img_h, - ) - if hole_img: - hx = img_x + (img_w - hole_img.width) // 2 - hy = (ch - hole_img.height) // 2 - img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) - - # Zone badge at bottom-right corner over the hole image - zone = hole_info.get("zone") - if zone and self.tier != "tiny": - badge = zone.upper() - bw = self._text_width(draw, badge, self.font_detail) + 4 - bx = cw - bw - 1 - by = ch - 9 - draw.rectangle([(bx, by), (cw - 1, ch - 1)], - fill=COLORS["masters_dark"]) - draw.text((bx + 2, by + 1), badge, - fill=COLORS["masters_yellow"], font=self.font_detail) + if show_image: + # Right side: hole layout image + img_x = left_w + 2 + actual_img_w = cw - img_x - 2 + img_h = ch - 4 + hole_img = self.logo_loader.get_hole_image( + hole_number, + max_width=actual_img_w, + max_height=img_h, + ) + if hole_img: + hx = img_x + (actual_img_w - hole_img.width) // 2 + hy = (ch - hole_img.height) // 2 + img.paste(hole_img, (hx, hy), hole_img if hole_img.mode == "RGBA" else None) + + # Zone badge at bottom-right corner over the hole image + zone = hole_info.get("zone") + if zone and self.tier != "tiny": + badge = zone.upper() + bw = self._text_width(draw, badge, self.font_detail) + 4 + bx = cw - bw - 1 + by = ch - 9 + draw.rectangle([(bx, by), (cw - 1, ch - 1)], + fill=COLORS["masters_dark"]) + draw.text((bx + 2, by + 1), badge, + fill=COLORS["masters_yellow"], font=self.font_detail) return img @@ -381,86 +390,85 @@ def _render_hole_card_compact(self, img, draw, hole_number: int, ch: Optional[int] = None) -> Image.Image: """Compact hole card for vertical resolutions below 48px. - Par and Yards sit to the RIGHT of Hole#/Name instead of underneath - so everything fits in less vertical space. Zone badge is drawn - under Par/Yards when there's room. + All text stacked on the left, course image on the right. Layout: - ┌─────────────┬─────────────┐ - │ #12 │ Par 3 │ - │ Golden Bell │ 155y │ - │ │ (AMEN COR) │ - └─────────────┴─────────────┘ - - Uses the 5by7 font for the text — slightly narrower than the - default 4x6 so long hole names like "Golden Bell" fit more - comfortably in the left column. + ┌──────────────┬────────────┐ + │ #12 │ │ + │ Golden Bell │ [course] │ + │ Par 3 155y │ [ map ] │ + │ AMEN CORNER │ │ + └──────────────┴────────────┘ """ if cw is None: cw = self.width if ch is None: ch = self.height - # 5x7 BDF bitmap font — pixel-perfect at native 5x7 size. PIL's TTF - # anti-aliasing washes out small pixel fonts, so we load the BDF - # directly via PIL.BdfFontFile for crisp 1:1 glyph rendering. Falls - # back to self.font_detail / self.font_body if the BDF file isn't on - # the search path. text_font = _load_bdf_font("5x7.bdf") or self.font_detail hole_font = _load_bdf_font("5x7.bdf") or self.font_body - # --- LEGACY 4x6 path, kept commented for quick revert if the 5x7 - # --- BDF causes issues on the Pi. Swap the two blocks to revert. - # text_font = _load_font_sized("4x6-font.ttf", 7) or self.font_detail - # hole_font = _load_font_sized("4x6-font.ttf", 8) or self.font_body - - col_w = cw // 2 - # Divider - draw.line([(col_w, 1), (col_w, ch - 2)], - fill=COLORS["masters_yellow"]) + # Text takes ~half, image gets the rest. + # If the card is too narrow for both columns, skip the image. + min_text_w = 36 + min_img_w = 20 + text_w = max(min_text_w, cw // 2) + img_w = cw - text_w - 2 + show_image = img_w >= min_img_w + if not show_image: + text_w = cw + img_x = text_w + 1 + max_text_w = text_w - 3 line_h = self._text_height(draw, "Ag", text_font) + 1 + y = 0 - # ── Left column: hole number (top) + name (underneath) ── + # Hole number hole_text = f"#{hole_number}" - hw = self._text_width(draw, hole_text, hole_font) - hole_h = self._text_height(draw, hole_text, hole_font) - draw.text(((col_w - hw) // 2, 1), hole_text, + draw.text((1, y), hole_text, fill=COLORS["white"], font=hole_font) + y += line_h + # Hole name name_text = hole_info["name"] - max_name_w = col_w - 4 - while name_text and self._text_width(draw, name_text, text_font) > max_name_w: + while name_text and self._text_width(draw, name_text, text_font) > max_text_w: name_text = name_text[:-1] - name_y = 1 + hole_h + 2 - nw = self._text_width(draw, name_text, text_font) - draw.text(((col_w - nw) // 2, name_y), name_text, + draw.text((1, y), name_text, fill=COLORS["masters_yellow"], font=text_font) - - # ── Right column: Par / yardage [/ zone] stacked ── - rx = col_w + 3 - right_w = cw - rx - 2 - y = 1 - - par_text = f"Par {hole_info['par']}" - draw.text((rx, y), par_text, - fill=COLORS["white"], font=text_font) y += line_h - yard_text = f"{hole_info['yardage']}y" - draw.text((rx, y), yard_text, - fill=COLORS["light_gray"], font=text_font) + # Par + yardage on same line + info_text = f"Par {hole_info['par']} {hole_info['yardage']}y" + while info_text and self._text_width(draw, info_text, text_font) > max_text_w: + info_text = info_text[:-1] + draw.text((1, y), info_text, + fill=COLORS["white"], font=text_font) y += line_h - # Zone only if there's a full text row of headroom left + # Zone if room zone = hole_info.get("zone") - if zone and y + line_h <= ch - 1: + if zone and y + line_h <= ch: zone_text = zone.upper() - while zone_text and self._text_width(draw, zone_text, text_font) > right_w: + while zone_text and self._text_width(draw, zone_text, text_font) > max_text_w: zone_text = zone_text[:-1] - draw.text((rx, y), zone_text, + draw.text((1, y), zone_text, fill=COLORS["masters_yellow"], font=text_font) + if show_image: + # Divider between text and image + draw.line([(text_w, 0), (text_w, ch - 1)], + fill=COLORS["masters_yellow"]) + + # Course image on the right + hole_img = self.logo_loader.get_hole_image( + hole_number, max_width=img_w, max_height=ch - 2, + ) + if hole_img: + hx = img_x + (img_w - hole_img.width) // 2 + hy = (ch - hole_img.height) // 2 + img.paste(hole_img, (hx, hy), + hole_img if hole_img.mode == "RGBA" else None) + return img def render_live_alert(