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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-12 23:53:35 -05:00
parent 41c50dac4f
commit 74294c38ce
4 changed files with 146 additions and 21 deletions

View File

@ -1,3 +1,5 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
@ -5,16 +7,55 @@ from fastapi.openapi.utils import get_openapi
# from fastapi.staticfiles import StaticFiles
# from fastapi.templating import Jinja2Templates
from .routers_v2.players import get_browser, shutdown_browser
from .routers_v2 import (
current, awards, teams, rarity, cardsets, players, packtypes, packs, cards, events, results, rewards, decisions,
batstats, pitstats, notifications, paperdex, gamerewards, gauntletrewards, gauntletruns, battingcards,
battingcardratings, pitchingcards, pitchingcardratings, cardpositions, scouting, mlbplayers, stratgame, stratplays)
current,
awards,
teams,
rarity,
cardsets,
players,
packtypes,
packs,
cards,
events,
results,
rewards,
decisions,
batstats,
pitstats,
notifications,
paperdex,
gamerewards,
gauntletrewards,
gauntletruns,
battingcards,
battingcardratings,
pitchingcards,
pitchingcardratings,
cardpositions,
scouting,
mlbplayers,
stratgame,
stratplays,
)
@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',
responses={404: {'description': 'Not found'}},
docs_url='/api/docs',
redoc_url='/api/redoc'
lifespan=lifespan,
responses={404: {"description": "Not found"}},
docs_url="/api/docs",
redoc_url="/api/redoc",
)
# app.mount("/static", StaticFiles(directory="storage/static"), name="static")
@ -54,9 +95,11 @@ app.include_router(decisions.router)
@app.get("/api/docs", include_in_schema=False)
async def get_docs(req: Request):
print(req.scope)
return get_swagger_ui_html(openapi_url=req.scope.get('root_path')+'/openapi.json', title='Swagger')
return get_swagger_ui_html(
openapi_url=req.scope.get("root_path") + "/openapi.json", title="Swagger"
)
@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)

View File

@ -1,17 +1,15 @@
import datetime
import os.path
import base64
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query
from fastapi.responses import FileResponse
from fastapi.templating import Jinja2Templates
from html2image import Html2Image
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 (
@ -19,7 +17,6 @@ from ..db_engine import (
Player,
model_to_dict,
fn,
chunked,
Paperdex,
Cardset,
Rarity,
@ -33,6 +30,57 @@ from ..db_engine import (
from ..db_helpers import upsert_players
from ..dependencies import oauth2_scheme, valid_token, LOG_DATA
# ---------------------------------------------------------------------------
# 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 = {
@ -153,7 +201,7 @@ async def get_players(
all_players = Player.select()
if all_players.count() == 0:
db.close()
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())
@ -692,7 +740,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 (
@ -819,16 +867,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',

View File

@ -2,9 +2,6 @@
<html lang="en">
<head>
{% include 'style.html' %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;700&family=Source+Sans+3:wght@400;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="fullCard" style="width: 1200px; height: 600px;">

File diff suppressed because one or more lines are too long