From c262bb431ebfd84d98581eb5f619c947723aa892 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 12 Mar 2026 23:53:35 -0500 Subject: [PATCH] feat: render pipeline optimization (Phase 0) - Persistent Chromium browser instance with auto-reconnect (WP-02) - FastAPI lifespan hooks for browser startup/shutdown (WP-03) - Self-hosted WOFF2 fonts via base64 embedding, remove Google Fonts CDN (WP-01) - Fix pre-existing lint issues (unused imports, f-string placeholders) Eliminates ~1.5s browser spawn overhead and ~0.4s font CDN round-trip per card render. Target: per-card render from ~3s to <1s. Refs: #88, #89, #90 Co-Authored-By: Claude Opus 4.6 --- app/main.py | 15 ++++++- app/routers_v2/players.py | 66 ++++++++++++++++++++++++++---- storage/templates/player_card.html | 3 -- storage/templates/style.html | 36 ++++++++++++++++ 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/app/main.py b/app/main.py index 64cbfc2..24909ee 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import logging import os +from contextlib import asynccontextmanager from datetime import datetime from fastapi import FastAPI, Request @@ -17,6 +18,7 @@ logging.basicConfig( # from fastapi.templating import Jinja2Templates from .db_engine import db +from .routers_v2.players import get_browser, shutdown_browser from .routers_v2 import ( current, awards, @@ -51,8 +53,19 @@ from .routers_v2 import ( scout_claims, ) + +@asynccontextmanager +async def lifespan(app): + # Startup: warm up the persistent Chromium browser + await get_browser() + yield + # Shutdown: clean up browser and playwright + await shutdown_browser() + + app = FastAPI( # root_path='/api', + lifespan=lifespan, responses={404: {"description": "Not found"}}, docs_url="/api/docs", redoc_url="/api/redoc", @@ -114,4 +127,4 @@ async def get_docs(req: Request): @app.get("/api/openapi.json", include_in_schema=False) async def openapi(): - return get_openapi(title="Paper Dynasty API", version=f"0.1.1", routes=app.routes) + return get_openapi(title="Paper Dynasty API", version="0.1.1", routes=app.routes) diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index 25a8363..eac10a4 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -9,7 +9,7 @@ from typing import Optional, List, Literal import logging import pydantic from pandas import DataFrame -from playwright.async_api import async_playwright +from playwright.async_api import async_playwright, Browser, Playwright from ..card_creation import get_batter_card_data, get_pitcher_card_data from ..db_engine import ( @@ -31,6 +31,57 @@ from ..db_engine import ( from ..db_helpers import upsert_players from ..dependencies import oauth2_scheme, valid_token +# --------------------------------------------------------------------------- +# Persistent browser instance (WP-02) +# --------------------------------------------------------------------------- + +_browser: Browser | None = None +_playwright: Playwright | None = None + + +async def get_browser() -> Browser: + """Get or create persistent Chromium browser instance. + + Reuses a single browser across all card renders, eliminating the ~1-1.5s + per-request launch/teardown overhead. Automatically reconnects if the + browser process has died. + """ + global _browser, _playwright + if _browser is None or not _browser.is_connected(): + if _playwright is not None: + try: + await _playwright.stop() + except Exception: + pass + _playwright = await async_playwright().start() + _browser = await _playwright.chromium.launch( + args=["--no-sandbox", "--disable-dev-shm-usage"] + ) + return _browser + + +async def shutdown_browser(): + """Clean shutdown of the persistent browser. + + Called by the FastAPI lifespan handler on application exit so the + Chromium process is not left orphaned. + """ + global _browser, _playwright + if _browser: + try: + await _browser.close() + except Exception: + pass + _browser = None + if _playwright: + try: + await _playwright.stop() + except Exception: + pass + _playwright = None + + +# --------------------------------------------------------------------------- # Franchise normalization: Convert city+team names to city-agnostic team names # This enables cross-era player matching (e.g., 'Oakland Athletics' -> 'Athletics') FRANCHISE_NORMALIZE = { @@ -144,7 +195,7 @@ async def get_players( ): all_players = Player.select() if all_players.count() == 0: - raise HTTPException(status_code=404, detail=f"There are no players to filter") + raise HTTPException(status_code=404, detail="There are no players to filter") if name is not None: all_players = all_players.where(fn.Lower(Player.p_name) == name.lower()) @@ -674,7 +725,7 @@ async def get_batter_card( ) headers = {"Cache-Control": "public, max-age=86400"} - filename = ( + _filename = ( f"{this_player.description} {this_player.p_name} {card_type} {d}-v{variant}" ) if ( @@ -799,16 +850,17 @@ async def get_batter_card( logging.debug(f"body:\n{html_response.body.decode('UTF-8')}") file_path = f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png" - async with async_playwright() as p: - browser = await p.chromium.launch() - page = await browser.new_page() + browser = await get_browser() + page = await browser.new_page(viewport={"width": 1280, "height": 720}) + try: await page.set_content(html_response.body.decode("UTF-8")) await page.screenshot( path=file_path, type="png", clip={"x": 0.0, "y": 0, "width": 1200, "height": 600}, ) - await browser.close() + finally: + await page.close() # hti = Html2Image( # browser='chrome', diff --git a/storage/templates/player_card.html b/storage/templates/player_card.html index 83750ed..9cdf814 100644 --- a/storage/templates/player_card.html +++ b/storage/templates/player_card.html @@ -2,9 +2,6 @@ {% include 'style.html' %} - - -
diff --git a/storage/templates/style.html b/storage/templates/style.html index 6ca98a2..d294d95 100644 --- a/storage/templates/style.html +++ b/storage/templates/style.html @@ -1,4 +1,40 @@